@ironbee-ai/cli 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (362) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +167 -39
  3. package/dist/analysis/code-changes.js.map +1 -1
  4. package/dist/analysis/cross-session.js.map +1 -1
  5. package/dist/analysis/fix-effectiveness.js.map +1 -1
  6. package/dist/analysis/time-analysis.js.map +1 -1
  7. package/dist/analysis/verdict-details.js.map +1 -1
  8. package/dist/analysis/verification-quality.js.map +1 -1
  9. package/dist/analytics/classifier.d.ts +99 -0
  10. package/dist/analytics/classifier.d.ts.map +1 -0
  11. package/dist/analytics/classifier.js +380 -0
  12. package/dist/analytics/classifier.js.map +1 -0
  13. package/dist/analytics/emit.d.ts +67 -0
  14. package/dist/analytics/emit.d.ts.map +1 -0
  15. package/dist/analytics/emit.js +901 -0
  16. package/dist/analytics/emit.js.map +1 -0
  17. package/dist/analytics/errors.d.ts +33 -0
  18. package/dist/analytics/errors.d.ts.map +1 -0
  19. package/dist/analytics/errors.js +93 -0
  20. package/dist/analytics/errors.js.map +1 -0
  21. package/dist/analytics/hook-trigger.d.ts +39 -0
  22. package/dist/analytics/hook-trigger.d.ts.map +1 -0
  23. package/dist/analytics/hook-trigger.js +127 -0
  24. package/dist/analytics/hook-trigger.js.map +1 -0
  25. package/dist/analytics/log.d.ts +44 -0
  26. package/dist/analytics/log.d.ts.map +1 -0
  27. package/dist/analytics/log.js +158 -0
  28. package/dist/analytics/log.js.map +1 -0
  29. package/dist/analytics/merge.d.ts +40 -0
  30. package/dist/analytics/merge.d.ts.map +1 -0
  31. package/dist/analytics/merge.js +527 -0
  32. package/dist/analytics/merge.js.map +1 -0
  33. package/dist/analytics/pricing.d.ts +149 -0
  34. package/dist/analytics/pricing.d.ts.map +1 -0
  35. package/dist/analytics/pricing.js +179 -0
  36. package/dist/analytics/pricing.js.map +1 -0
  37. package/dist/analytics/projection.d.ts +356 -0
  38. package/dist/analytics/projection.d.ts.map +1 -0
  39. package/dist/analytics/projection.js +2281 -0
  40. package/dist/analytics/projection.js.map +1 -0
  41. package/dist/analytics/spawn.d.ts +28 -0
  42. package/dist/analytics/spawn.d.ts.map +1 -0
  43. package/dist/analytics/spawn.js +57 -0
  44. package/dist/analytics/spawn.js.map +1 -0
  45. package/dist/analytics/state.d.ts +58 -0
  46. package/dist/analytics/state.d.ts.map +1 -0
  47. package/dist/analytics/state.js +329 -0
  48. package/dist/analytics/state.js.map +1 -0
  49. package/dist/analytics/transcript.d.ts +150 -0
  50. package/dist/analytics/transcript.d.ts.map +1 -0
  51. package/dist/analytics/transcript.js +276 -0
  52. package/dist/analytics/transcript.js.map +1 -0
  53. package/dist/analytics/types.d.ts +875 -0
  54. package/dist/analytics/types.d.ts.map +1 -0
  55. package/dist/analytics/types.js +31 -0
  56. package/dist/analytics/types.js.map +1 -0
  57. package/dist/clients/base.d.ts +21 -2
  58. package/dist/clients/base.d.ts.map +1 -1
  59. package/dist/clients/claude/commands/ironbee-verify.md +15 -7
  60. package/dist/clients/claude/fragments/command-verify.node.md +33 -0
  61. package/dist/clients/claude/fragments/rule.node.md +29 -0
  62. package/dist/clients/claude/fragments/skill.node.md +77 -0
  63. package/dist/clients/claude/hooks/activity-end.d.ts +13 -0
  64. package/dist/clients/claude/hooks/activity-end.d.ts.map +1 -0
  65. package/dist/clients/claude/hooks/activity-end.js +42 -0
  66. package/dist/clients/claude/hooks/activity-end.js.map +1 -0
  67. package/dist/clients/claude/hooks/require-verdict.d.ts +3 -2
  68. package/dist/clients/claude/hooks/require-verdict.d.ts.map +1 -1
  69. package/dist/clients/claude/hooks/require-verdict.js +6 -5
  70. package/dist/clients/claude/hooks/require-verdict.js.map +1 -1
  71. package/dist/clients/claude/hooks/require-verification.d.ts +7 -4
  72. package/dist/clients/claude/hooks/require-verification.d.ts.map +1 -1
  73. package/dist/clients/claude/hooks/require-verification.js +44 -22
  74. package/dist/clients/claude/hooks/require-verification.js.map +1 -1
  75. package/dist/clients/claude/hooks/session-end.d.ts.map +1 -1
  76. package/dist/clients/claude/hooks/session-end.js +17 -2
  77. package/dist/clients/claude/hooks/session-end.js.map +1 -1
  78. package/dist/clients/claude/hooks/session-start.d.ts.map +1 -1
  79. package/dist/clients/claude/hooks/session-start.js +2 -1
  80. package/dist/clients/claude/hooks/session-start.js.map +1 -1
  81. package/dist/clients/claude/hooks/track-action-monitor.d.ts +27 -0
  82. package/dist/clients/claude/hooks/track-action-monitor.d.ts.map +1 -0
  83. package/dist/clients/claude/hooks/track-action-monitor.js +126 -0
  84. package/dist/clients/claude/hooks/track-action-monitor.js.map +1 -0
  85. package/dist/clients/claude/hooks/track-action.d.ts.map +1 -1
  86. package/dist/clients/claude/hooks/track-action.js +29 -20
  87. package/dist/clients/claude/hooks/track-action.js.map +1 -1
  88. package/dist/clients/claude/hooks/verify-gate.d.ts.map +1 -1
  89. package/dist/clients/claude/hooks/verify-gate.js +18 -1
  90. package/dist/clients/claude/hooks/verify-gate.js.map +1 -1
  91. package/dist/clients/claude/index.d.ts +4 -1
  92. package/dist/clients/claude/index.d.ts.map +1 -1
  93. package/dist/clients/claude/index.js +171 -94
  94. package/dist/clients/claude/index.js.map +1 -1
  95. package/dist/clients/claude/rules/ironbee-verification.md +41 -33
  96. package/dist/clients/claude/skills/ironbee-verification.md +93 -76
  97. package/dist/clients/cursor/commands/ironbee-verify/SKILL.md +18 -10
  98. package/dist/clients/cursor/fragments/command-verify.node.md +33 -0
  99. package/dist/clients/cursor/fragments/rule.node.md +29 -0
  100. package/dist/clients/cursor/fragments/skill.node.md +77 -0
  101. package/dist/clients/cursor/hooks/activity-end.d.ts +14 -0
  102. package/dist/clients/cursor/hooks/activity-end.d.ts.map +1 -0
  103. package/dist/clients/cursor/hooks/activity-end.js +45 -0
  104. package/dist/clients/cursor/hooks/activity-end.js.map +1 -0
  105. package/dist/clients/cursor/hooks/require-verdict.d.ts +1 -1
  106. package/dist/clients/cursor/hooks/require-verdict.js +4 -4
  107. package/dist/clients/cursor/hooks/require-verification.d.ts.map +1 -1
  108. package/dist/clients/cursor/hooks/require-verification.js +42 -16
  109. package/dist/clients/cursor/hooks/require-verification.js.map +1 -1
  110. package/dist/clients/cursor/hooks/session-end.d.ts.map +1 -1
  111. package/dist/clients/cursor/hooks/session-end.js +18 -2
  112. package/dist/clients/cursor/hooks/session-end.js.map +1 -1
  113. package/dist/clients/cursor/hooks/session-start.d.ts.map +1 -1
  114. package/dist/clients/cursor/hooks/session-start.js +2 -1
  115. package/dist/clients/cursor/hooks/session-start.js.map +1 -1
  116. package/dist/clients/cursor/hooks/track-action-monitor.d.ts +27 -0
  117. package/dist/clients/cursor/hooks/track-action-monitor.d.ts.map +1 -0
  118. package/dist/clients/cursor/hooks/track-action-monitor.js +133 -0
  119. package/dist/clients/cursor/hooks/track-action-monitor.js.map +1 -0
  120. package/dist/clients/cursor/hooks/track-action.d.ts.map +1 -1
  121. package/dist/clients/cursor/hooks/track-action.js +51 -23
  122. package/dist/clients/cursor/hooks/track-action.js.map +1 -1
  123. package/dist/clients/cursor/hooks/verify-gate.d.ts.map +1 -1
  124. package/dist/clients/cursor/hooks/verify-gate.js +14 -1
  125. package/dist/clients/cursor/hooks/verify-gate.js.map +1 -1
  126. package/dist/clients/cursor/index.d.ts +4 -1
  127. package/dist/clients/cursor/index.d.ts.map +1 -1
  128. package/dist/clients/cursor/index.js +117 -71
  129. package/dist/clients/cursor/index.js.map +1 -1
  130. package/dist/clients/cursor/rules/ironbee-verification.mdc +37 -29
  131. package/dist/clients/cursor/skills/ironbee-verification.md +93 -76
  132. package/dist/clients/registry.d.ts +14 -0
  133. package/dist/clients/registry.d.ts.map +1 -1
  134. package/dist/clients/registry.js +34 -0
  135. package/dist/clients/registry.js.map +1 -1
  136. package/dist/commands/analyze.d.ts.map +1 -1
  137. package/dist/commands/analyze.js +40 -0
  138. package/dist/commands/analyze.js.map +1 -1
  139. package/dist/commands/backend-toggle.d.ts +45 -0
  140. package/dist/commands/backend-toggle.d.ts.map +1 -0
  141. package/dist/commands/backend-toggle.js +192 -0
  142. package/dist/commands/backend-toggle.js.map +1 -0
  143. package/dist/commands/disable-backend.d.ts +14 -0
  144. package/dist/commands/disable-backend.d.ts.map +1 -0
  145. package/dist/commands/disable-backend.js +34 -0
  146. package/dist/commands/disable-backend.js.map +1 -0
  147. package/dist/commands/disable-verification.d.ts +16 -0
  148. package/dist/commands/disable-verification.d.ts.map +1 -0
  149. package/dist/commands/disable-verification.js +36 -0
  150. package/dist/commands/disable-verification.js.map +1 -0
  151. package/dist/commands/enable-backend.d.ts +15 -0
  152. package/dist/commands/enable-backend.d.ts.map +1 -0
  153. package/dist/commands/enable-backend.js +35 -0
  154. package/dist/commands/enable-backend.js.map +1 -0
  155. package/dist/commands/enable-verification.d.ts +14 -0
  156. package/dist/commands/enable-verification.d.ts.map +1 -0
  157. package/dist/commands/enable-verification.js +34 -0
  158. package/dist/commands/enable-verification.js.map +1 -0
  159. package/dist/commands/hook.d.ts.map +1 -1
  160. package/dist/commands/hook.js +60 -0
  161. package/dist/commands/hook.js.map +1 -1
  162. package/dist/commands/import.d.ts +39 -0
  163. package/dist/commands/import.d.ts.map +1 -0
  164. package/dist/commands/import.js +369 -0
  165. package/dist/commands/import.js.map +1 -0
  166. package/dist/commands/install.d.ts.map +1 -1
  167. package/dist/commands/install.js +15 -20
  168. package/dist/commands/install.js.map +1 -1
  169. package/dist/commands/process-analytics.d.ts +18 -0
  170. package/dist/commands/process-analytics.d.ts.map +1 -0
  171. package/dist/commands/process-analytics.js +57 -0
  172. package/dist/commands/process-analytics.js.map +1 -0
  173. package/dist/commands/queue.d.ts +2 -3
  174. package/dist/commands/queue.d.ts.map +1 -1
  175. package/dist/commands/queue.js +2 -3
  176. package/dist/commands/queue.js.map +1 -1
  177. package/dist/commands/status.d.ts.map +1 -1
  178. package/dist/commands/status.js +29 -1
  179. package/dist/commands/status.js.map +1 -1
  180. package/dist/commands/verification-toggle.d.ts +47 -0
  181. package/dist/commands/verification-toggle.d.ts.map +1 -0
  182. package/dist/commands/verification-toggle.js +113 -0
  183. package/dist/commands/verification-toggle.js.map +1 -0
  184. package/dist/commands/verify.d.ts.map +1 -1
  185. package/dist/commands/verify.js +28 -0
  186. package/dist/commands/verify.js.map +1 -1
  187. package/dist/hooks/core/actions.d.ts +64 -67
  188. package/dist/hooks/core/actions.d.ts.map +1 -1
  189. package/dist/hooks/core/actions.js +39 -24
  190. package/dist/hooks/core/actions.js.map +1 -1
  191. package/dist/hooks/core/activity-end.d.ts +20 -0
  192. package/dist/hooks/core/activity-end.d.ts.map +1 -0
  193. package/dist/hooks/core/activity-end.js +23 -0
  194. package/dist/hooks/core/activity-end.js.map +1 -0
  195. package/dist/hooks/core/required-tools.d.ts +30 -0
  196. package/dist/hooks/core/required-tools.d.ts.map +1 -0
  197. package/dist/hooks/core/required-tools.js +70 -0
  198. package/dist/hooks/core/required-tools.js.map +1 -0
  199. package/dist/hooks/core/session-state.d.ts +12 -3
  200. package/dist/hooks/core/session-state.d.ts.map +1 -1
  201. package/dist/hooks/core/session-state.js +59 -0
  202. package/dist/hooks/core/session-state.js.map +1 -1
  203. package/dist/hooks/core/submit-verdict.d.ts.map +1 -1
  204. package/dist/hooks/core/submit-verdict.js +16 -12
  205. package/dist/hooks/core/submit-verdict.js.map +1 -1
  206. package/dist/hooks/core/verify-gate.d.ts +17 -3
  207. package/dist/hooks/core/verify-gate.d.ts.map +1 -1
  208. package/dist/hooks/core/verify-gate.js +312 -116
  209. package/dist/hooks/core/verify-gate.js.map +1 -1
  210. package/dist/import/claude/analytics-runner.d.ts +42 -0
  211. package/dist/import/claude/analytics-runner.d.ts.map +1 -0
  212. package/dist/import/claude/analytics-runner.js +213 -0
  213. package/dist/import/claude/analytics-runner.js.map +1 -0
  214. package/dist/import/claude/discovery.d.ts +22 -0
  215. package/dist/import/claude/discovery.d.ts.map +1 -0
  216. package/dist/import/claude/discovery.js +197 -0
  217. package/dist/import/claude/discovery.js.map +1 -0
  218. package/dist/import/claude/encoding.d.ts +50 -0
  219. package/dist/import/claude/encoding.d.ts.map +1 -0
  220. package/dist/import/claude/encoding.js +110 -0
  221. package/dist/import/claude/encoding.js.map +1 -0
  222. package/dist/import/claude/events/file-change.d.ts +28 -0
  223. package/dist/import/claude/events/file-change.d.ts.map +1 -0
  224. package/dist/import/claude/events/file-change.js +112 -0
  225. package/dist/import/claude/events/file-change.js.map +1 -0
  226. package/dist/import/claude/events/tool-call.d.ts +61 -0
  227. package/dist/import/claude/events/tool-call.d.ts.map +1 -0
  228. package/dist/import/claude/events/tool-call.js +119 -0
  229. package/dist/import/claude/events/tool-call.js.map +1 -0
  230. package/dist/import/claude/runner.d.ts +31 -0
  231. package/dist/import/claude/runner.d.ts.map +1 -0
  232. package/dist/import/claude/runner.js +280 -0
  233. package/dist/import/claude/runner.js.map +1 -0
  234. package/dist/import/claude/summary.d.ts +23 -0
  235. package/dist/import/claude/summary.d.ts.map +1 -0
  236. package/dist/import/claude/summary.js +186 -0
  237. package/dist/import/claude/summary.js.map +1 -0
  238. package/dist/import/claude/transcript-walk.d.ts +52 -0
  239. package/dist/import/claude/transcript-walk.d.ts.map +1 -0
  240. package/dist/import/claude/transcript-walk.js +187 -0
  241. package/dist/import/claude/transcript-walk.js.map +1 -0
  242. package/dist/import/concurrent-pool.d.ts +45 -0
  243. package/dist/import/concurrent-pool.d.ts.map +1 -0
  244. package/dist/import/concurrent-pool.js +95 -0
  245. package/dist/import/concurrent-pool.js.map +1 -0
  246. package/dist/import/emitter.d.ts +29 -0
  247. package/dist/import/emitter.d.ts.map +1 -0
  248. package/dist/import/emitter.js +66 -0
  249. package/dist/import/emitter.js.map +1 -0
  250. package/dist/import/events/activity.d.ts +23 -0
  251. package/dist/import/events/activity.d.ts.map +1 -0
  252. package/dist/import/events/activity.js +45 -0
  253. package/dist/import/events/activity.js.map +1 -0
  254. package/dist/import/events/session.d.ts +24 -0
  255. package/dist/import/events/session.d.ts.map +1 -0
  256. package/dist/import/events/session.js +47 -0
  257. package/dist/import/events/session.js.map +1 -0
  258. package/dist/import/filter.d.ts +47 -0
  259. package/dist/import/filter.d.ts.map +1 -0
  260. package/dist/import/filter.js +90 -0
  261. package/dist/import/filter.js.map +1 -0
  262. package/dist/import/ids.d.ts +56 -0
  263. package/dist/import/ids.d.ts.map +1 -0
  264. package/dist/import/ids.js +87 -0
  265. package/dist/import/ids.js.map +1 -0
  266. package/dist/import/index.d.ts +29 -0
  267. package/dist/import/index.d.ts.map +1 -0
  268. package/dist/import/index.js +52 -0
  269. package/dist/import/index.js.map +1 -0
  270. package/dist/import/marker.d.ts +20 -0
  271. package/dist/import/marker.d.ts.map +1 -0
  272. package/dist/import/marker.js +71 -0
  273. package/dist/import/marker.js.map +1 -0
  274. package/dist/import/pipeline.d.ts +41 -0
  275. package/dist/import/pipeline.d.ts.map +1 -0
  276. package/dist/import/pipeline.js +47 -0
  277. package/dist/import/pipeline.js.map +1 -0
  278. package/dist/import/progress.d.ts +20 -0
  279. package/dist/import/progress.d.ts.map +1 -0
  280. package/dist/import/progress.js +69 -0
  281. package/dist/import/progress.js.map +1 -0
  282. package/dist/import/skip.d.ts +13 -0
  283. package/dist/import/skip.d.ts.map +1 -0
  284. package/dist/import/skip.js +24 -0
  285. package/dist/import/skip.js.map +1 -0
  286. package/dist/import/types.d.ts +125 -0
  287. package/dist/import/types.d.ts.map +1 -0
  288. package/dist/import/types.js +28 -0
  289. package/dist/import/types.js.map +1 -0
  290. package/dist/index.js +21 -2
  291. package/dist/index.js.map +1 -1
  292. package/dist/lib/collector.d.ts +29 -3
  293. package/dist/lib/collector.d.ts.map +1 -1
  294. package/dist/lib/collector.js +118 -8
  295. package/dist/lib/collector.js.map +1 -1
  296. package/dist/lib/config.d.ts +240 -83
  297. package/dist/lib/config.d.ts.map +1 -1
  298. package/dist/lib/config.js +482 -89
  299. package/dist/lib/config.js.map +1 -1
  300. package/dist/lib/event.d.ts +72 -0
  301. package/dist/lib/event.d.ts.map +1 -0
  302. package/dist/lib/event.js +42 -0
  303. package/dist/lib/event.js.map +1 -0
  304. package/dist/lib/gitignore.d.ts +21 -0
  305. package/dist/lib/gitignore.d.ts.map +1 -0
  306. package/dist/lib/gitignore.js +54 -0
  307. package/dist/lib/gitignore.js.map +1 -0
  308. package/dist/lib/runtime-section.d.ts +118 -0
  309. package/dist/lib/runtime-section.d.ts.map +1 -0
  310. package/dist/lib/runtime-section.js +256 -0
  311. package/dist/lib/runtime-section.js.map +1 -0
  312. package/dist/lib/telemetry.d.ts +1 -1
  313. package/dist/lib/telemetry.d.ts.map +1 -1
  314. package/dist/lib/telemetry.js +4 -1
  315. package/dist/lib/telemetry.js.map +1 -1
  316. package/dist/queue/dead-letter.d.ts +5 -1
  317. package/dist/queue/dead-letter.d.ts.map +1 -1
  318. package/dist/queue/dead-letter.js +5 -1
  319. package/dist/queue/dead-letter.js.map +1 -1
  320. package/dist/queue/drain.d.ts +3 -2
  321. package/dist/queue/drain.d.ts.map +1 -1
  322. package/dist/queue/drain.js +3 -2
  323. package/dist/queue/drain.js.map +1 -1
  324. package/dist/queue/flush.d.ts +28 -12
  325. package/dist/queue/flush.d.ts.map +1 -1
  326. package/dist/queue/flush.js +43 -18
  327. package/dist/queue/flush.js.map +1 -1
  328. package/dist/queue/handlers/send-event.d.ts.map +1 -1
  329. package/dist/queue/handlers/send-event.js.map +1 -1
  330. package/dist/queue/index.d.ts +1 -2
  331. package/dist/queue/index.d.ts.map +1 -1
  332. package/dist/queue/index.js +2 -2
  333. package/dist/queue/index.js.map +1 -1
  334. package/dist/queue/paths.d.ts +4 -2
  335. package/dist/queue/paths.d.ts.map +1 -1
  336. package/dist/queue/paths.js +4 -2
  337. package/dist/queue/paths.js.map +1 -1
  338. package/dist/queue/process-file.d.ts +5 -1
  339. package/dist/queue/process-file.d.ts.map +1 -1
  340. package/dist/queue/process-file.js +5 -1
  341. package/dist/queue/process-file.js.map +1 -1
  342. package/dist/queue/snapshot.d.ts +4 -1
  343. package/dist/queue/snapshot.d.ts.map +1 -1
  344. package/dist/queue/snapshot.js +4 -1
  345. package/dist/queue/snapshot.js.map +1 -1
  346. package/dist/queue/spawn.d.ts +1 -3
  347. package/dist/queue/spawn.d.ts.map +1 -1
  348. package/dist/queue/spawn.js +1 -3
  349. package/dist/queue/spawn.js.map +1 -1
  350. package/dist/queue/submit.d.ts +6 -1
  351. package/dist/queue/submit.d.ts.map +1 -1
  352. package/dist/queue/submit.js +6 -1
  353. package/dist/queue/submit.js.map +1 -1
  354. package/dist/queue/types.d.ts +5 -1
  355. package/dist/queue/types.d.ts.map +1 -1
  356. package/dist/queue/types.js +5 -1
  357. package/dist/queue/types.js.map +1 -1
  358. package/dist/queue/worker-log.d.ts +3 -1
  359. package/dist/queue/worker-log.d.ts.map +1 -1
  360. package/dist/queue/worker-log.js +3 -1
  361. package/dist/queue/worker-log.js.map +1 -1
  362. package/package.json +1 -1
@@ -0,0 +1,2281 @@
1
+ "use strict";
2
+ /**
3
+ * IronBee CLI — Analytics Projection
4
+ *
5
+ * Projects a slice of host transcript JSONL lines into a delta
6
+ * `SessionAnalytics`. Operates on a slice (not the full file) so
7
+ * incremental projection is cheap; emits no content (privacy fence).
8
+ *
9
+ * Inputs:
10
+ * - parsed transcript lines (already JSON-parsed by transcript.ts)
11
+ * - `startingTurnIndex`: 1-based assistant message index for the FIRST
12
+ * assistant message in this slice. Used by the turn-bucket logic to
13
+ * assign global turn buckets across multiple incremental projections.
14
+ *
15
+ * Output: `DeltaAnalytics` — same shape as `AccumulatedAnalytics` plus
16
+ * delta-local signals merge needs (`has_assistant_with_usage`,
17
+ * `closing_pending_tool_uses`, `closing_current_turn`).
18
+ *
19
+ * Privacy: the function reads everything (Bash command body, Edit
20
+ * old_string/new_string, Write content, tool_result.content, user msg
21
+ * text for classifier keyword matching) but emits ONLY counts, categories,
22
+ * paths, timestamps, byte sizes, and boolean flags. No raw text leaves
23
+ * this function. Per-tool / per-category attribution operate on the
24
+ * read-once-locally pattern.
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.deriveStepId = deriveStepId;
28
+ exports.deriveTurnId = deriveTurnId;
29
+ exports.formatHexAsUuid = formatHexAsUuid;
30
+ exports.deriveSessionAnalyticsEventId = deriveSessionAnalyticsEventId;
31
+ exports.deriveTurnEventId = deriveTurnEventId;
32
+ exports.deriveStepEventId = deriveStepEventId;
33
+ exports.applyBreakdownDelta = applyBreakdownDelta;
34
+ exports.closeTurn = closeTurn;
35
+ exports.projectDelta = projectDelta;
36
+ exports.projectDeltaInternal = projectDeltaInternal;
37
+ const node_crypto_1 = require("node:crypto");
38
+ const diff_1 = require("diff");
39
+ const logger_1 = require("../lib/logger");
40
+ const types_1 = require("./types");
41
+ const pricing_1 = require("./pricing");
42
+ const errors_1 = require("./errors");
43
+ const classifier_1 = require("./classifier");
44
+ // ─────────────────────────────────────────────────────────────────────────
45
+ // Constants — keep in sync with /insights:332-349 + 651-677 + 651-677
46
+ // ─────────────────────────────────────────────────────────────────────────
47
+ const EXTENSION_TO_LANGUAGE = {
48
+ ".ts": "TypeScript",
49
+ ".tsx": "TypeScript",
50
+ ".js": "JavaScript",
51
+ ".jsx": "JavaScript",
52
+ ".py": "Python",
53
+ ".rb": "Ruby",
54
+ ".go": "Go",
55
+ ".rs": "Rust",
56
+ ".java": "Java",
57
+ ".md": "Markdown",
58
+ ".json": "JSON",
59
+ ".yaml": "YAML",
60
+ ".yml": "YAML",
61
+ ".sh": "Shell",
62
+ ".css": "CSS",
63
+ ".html": "HTML",
64
+ };
65
+ const INTERRUPT_MARKER = "[Request interrupted by user";
66
+ // Response-time clamp per /insights:633.
67
+ const RESPONSE_TIME_MIN_SEC = 2;
68
+ const RESPONSE_TIME_MAX_SEC = 3600;
69
+ // Hot files: per-session top-K cap kept in delta. The merge step uses the
70
+ // full path→count map from `internal` to recompute the wire top-K, so
71
+ // this constant doesn't need to match the wire size.
72
+ const DELTA_HOT_FILES_LIMIT = 100;
73
+ // ─────────────────────────────────────────────────────────────────────────
74
+ // Helpers
75
+ // ─────────────────────────────────────────────────────────────────────────
76
+ function getLanguageFromPath(filePath) {
77
+ const dot = filePath.lastIndexOf(".");
78
+ if (dot === -1) {
79
+ return null;
80
+ }
81
+ const ext = filePath.slice(dot).toLowerCase();
82
+ return EXTENSION_TO_LANGUAGE[ext] ?? null;
83
+ }
84
+ /** Skip leading env-var assignments (FOO=bar) when grabbing the binary token. */
85
+ function extractBashBinary(cmd) {
86
+ const tokens = cmd.trim().split(/\s+/);
87
+ for (const t of tokens) {
88
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) {
89
+ continue;
90
+ }
91
+ if (t.length > 0) {
92
+ return t;
93
+ }
94
+ }
95
+ return undefined;
96
+ }
97
+ /**
98
+ * "git commit" / "npm install" — first two tokens, but only for known
99
+ * multi-subcommand CLIs (closed list). For other binaries the second
100
+ * token may be a user-supplied argument that could carry sensitive
101
+ * content (e.g., `echo "secret"`); we don't include those.
102
+ *
103
+ * Returns just the binary name when the second token isn't a
104
+ * subcommand-identifier shape (must be alphanumeric, dot, dash, or
105
+ * underscore — no quotes, slashes, equals).
106
+ */
107
+ const KNOWN_MULTI_SUBCOMMAND_BINARIES = new Set([
108
+ "git", "npm", "yarn", "pnpm", "bun",
109
+ "cargo", "go", "uv", "pipx", "poetry",
110
+ "docker", "kubectl", "helm",
111
+ "brew", "apt", "apt-get",
112
+ "aws", "gcloud", "az",
113
+ "make", "deno", "rustup",
114
+ ]);
115
+ const SUBCOMMAND_TOKEN_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
116
+ function extractBashSubcommand(cmd) {
117
+ const tokens = cmd.trim().split(/\s+/);
118
+ const stripped = [];
119
+ for (const t of tokens) {
120
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t)) {
121
+ continue;
122
+ }
123
+ stripped.push(t);
124
+ }
125
+ if (stripped.length === 0) {
126
+ return undefined;
127
+ }
128
+ const binary = stripped[0];
129
+ if (stripped.length === 1) {
130
+ return binary;
131
+ }
132
+ // Only include subcommand for known multi-subcommand CLIs AND when the
133
+ // second token has a safe identifier shape. Otherwise just emit the binary.
134
+ if (KNOWN_MULTI_SUBCOMMAND_BINARIES.has(binary)
135
+ && SUBCOMMAND_TOKEN_RE.test(stripped[1])) {
136
+ return `${binary} ${stripped[1]}`;
137
+ }
138
+ return binary;
139
+ }
140
+ /**
141
+ * Classify an error response body into one of the closed-list categories.
142
+ * Reads the content text but only emits the label.
143
+ */
144
+ function classifyToolError(content) {
145
+ if (typeof content !== "string") {
146
+ return "other";
147
+ }
148
+ const lower = content.toLowerCase();
149
+ if (lower.includes("exit code")) {
150
+ return "command_failed";
151
+ }
152
+ if (lower.includes("rejected") || lower.includes("doesn't want")) {
153
+ return "user_rejected";
154
+ }
155
+ if (lower.includes("string to replace not found") || lower.includes("no changes")) {
156
+ return "edit_failed";
157
+ }
158
+ if (lower.includes("modified since read")) {
159
+ return "file_changed";
160
+ }
161
+ if (lower.includes("exceeds maximum") || lower.includes("too large")) {
162
+ return "file_too_large";
163
+ }
164
+ if (lower.includes("file not found") || lower.includes("does not exist")) {
165
+ return "file_not_found";
166
+ }
167
+ return "other";
168
+ }
169
+ function turnBucket(turnIndex) {
170
+ if (turnIndex <= 3) {
171
+ return "1-3";
172
+ }
173
+ if (turnIndex <= 10) {
174
+ return "4-10";
175
+ }
176
+ if (turnIndex <= 25) {
177
+ return "11-25";
178
+ }
179
+ if (turnIndex <= 50) {
180
+ return "26-50";
181
+ }
182
+ if (turnIndex <= 100) {
183
+ return "51-100";
184
+ }
185
+ return "100+";
186
+ }
187
+ /** Normalize timestamp to milliseconds-since-epoch; null if unparseable. */
188
+ function tsMs(ts) {
189
+ if (typeof ts !== "string" || ts.length === 0) {
190
+ return null;
191
+ }
192
+ const ms = Date.parse(ts);
193
+ return Number.isNaN(ms) ? null : ms;
194
+ }
195
+ /** Returns true iff this user message carries human-typed text (not just tool_result wrapping). */
196
+ /**
197
+ * `isMeta: true` is Claude Code's structural marker for host-injected user
198
+ * messages (slash command bodies, Stop-hook feedback strings, etc.). We do
199
+ * NOT use this as a filter to skip the line entirely — that would orphan
200
+ * assistant work in cases like verify-gate retry, where hook feedback
201
+ * triggers further agent activity. Instead, projection treats isMeta lines
202
+ * with continuation semantics:
203
+ *
204
+ * - isMeta + currentTurn exists → continuation (no new turn opens, no
205
+ * user_turns++; just extend the open turn's activity window).
206
+ * - isMeta + currentTurn is null → opens a NEW turn (used by hook feedback
207
+ * after a Stop force-closed the prior turn — retry assistant attributes
208
+ * to this new turn).
209
+ *
210
+ * This keeps `/ironbee-verify` (no retry) at 1 turn (slash body merges into
211
+ * the `<command-message>` turn), while a verify-gate retry produces 2 turns
212
+ * (initial work + retry block) with all assistant work attributed correctly.
213
+ *
214
+ * The earlier "isMeta = full skip" approach inadvertently caused retry asst
215
+ * orphan; "content sniffing" was even worse (fragile against wrapper format
216
+ * changes). Continuation is the structurally clean middle ground.
217
+ */
218
+ function isHumanTextUser(line) {
219
+ if (line.type !== "user" || line.message === undefined) {
220
+ return false;
221
+ }
222
+ const content = line.message.content;
223
+ if (typeof content === "string") {
224
+ return content.trim().length > 0;
225
+ }
226
+ if (Array.isArray(content)) {
227
+ for (const block of content) {
228
+ if (block !== null
229
+ && typeof block === "object"
230
+ && block.type === "text"
231
+ && typeof block.text === "string") {
232
+ return true;
233
+ }
234
+ }
235
+ }
236
+ return false;
237
+ }
238
+ /** Returns true iff any block of the user message is `[Request interrupted by user…]`. */
239
+ function isInterruptedUser(line) {
240
+ if (line.type !== "user" || line.message === undefined) {
241
+ return false;
242
+ }
243
+ const content = line.message.content;
244
+ if (typeof content === "string") {
245
+ return content.includes(INTERRUPT_MARKER);
246
+ }
247
+ if (Array.isArray(content)) {
248
+ for (const block of content) {
249
+ if (block !== null
250
+ && typeof block === "object"
251
+ && block.type === "text"
252
+ && typeof block.text === "string"
253
+ && block.text.includes(INTERRUPT_MARKER)) {
254
+ return true;
255
+ }
256
+ }
257
+ }
258
+ return false;
259
+ }
260
+ // ─────────────────────────────────────────────────────────────────────────
261
+ // Per-tool attribution helpers
262
+ // ─────────────────────────────────────────────────────────────────────────
263
+ /** Empty ToolUsage slot — used to lazily seed `tools[name]`. */
264
+ function emptyToolUsage() {
265
+ return {
266
+ count: 0,
267
+ errors: 0,
268
+ input_size: 0,
269
+ output_size: 0,
270
+ approximated_input_tokens: 0,
271
+ approximated_output_tokens: 0,
272
+ };
273
+ }
274
+ /**
275
+ * Map a response-time gap (ms) into one of the 7 canonical buckets used by
276
+ * downstream analytics. Mirrors Claude Code's `/insights` bucketing so the
277
+ * backend can render the same histogram without re-bucketing. Same clamp
278
+ * thresholds as `RESPONSE_TIME_MIN_SEC`/`RESPONSE_TIME_MAX_SEC` — values
279
+ * that would have been clamped out of `user_response_times_ms` are also
280
+ * not bucketed here.
281
+ */
282
+ function responseTimeBucket(ms) {
283
+ const sec = ms / 1000;
284
+ if (sec < RESPONSE_TIME_MIN_SEC || sec > RESPONSE_TIME_MAX_SEC) {
285
+ return null;
286
+ }
287
+ if (sec < 10) {
288
+ return "2-10s";
289
+ }
290
+ if (sec < 30) {
291
+ return "10-30s";
292
+ }
293
+ if (sec < 60) {
294
+ return "30s-1m";
295
+ }
296
+ if (sec < 120) {
297
+ return "1-2m";
298
+ }
299
+ if (sec < 300) {
300
+ return "2-5m";
301
+ }
302
+ if (sec < 900) {
303
+ return "5-15m";
304
+ }
305
+ return ">15m";
306
+ }
307
+ /** Format Date → "YYYY-MM-DD" (local timezone). */
308
+ function formatDateKey(d) {
309
+ const y = String(d.getFullYear());
310
+ const m = String(d.getMonth() + 1).padStart(2, "0");
311
+ const day = String(d.getDate()).padStart(2, "0");
312
+ return `${y}-${m}-${day}`;
313
+ }
314
+ const WEEKDAY_NAMES = [
315
+ "sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday",
316
+ ];
317
+ /** Format Date → "monday".."sunday" lowercase (local timezone). */
318
+ function formatWeekdayKey(d) {
319
+ return WEEKDAY_NAMES[d.getDay()];
320
+ }
321
+ /**
322
+ * Extract MCP server name from a tool name that follows Anthropic's
323
+ * protocol form `mcp__<server>__<tool>`. Returns the server segment, or
324
+ * `undefined` for non-MCP tools or malformed names. Local helper to keep
325
+ * analytics free of client-specific imports.
326
+ *
327
+ * Examples:
328
+ * `"mcp__browser-devtools__bdt_navigation_go-to"` → `"browser-devtools"`
329
+ * `"mcp__node-devtools__ndt_debug_connect"` → `"node-devtools"`
330
+ * `"Read"` → undefined
331
+ * `"mcp__broken"` → undefined
332
+ */
333
+ function extractMcpServer(toolName) {
334
+ if (!toolName.startsWith("mcp__")) {
335
+ return undefined;
336
+ }
337
+ const rest = toolName.slice("mcp__".length);
338
+ const sep = rest.indexOf("__");
339
+ if (sep <= 0) {
340
+ return undefined;
341
+ }
342
+ return rest.slice(0, sep);
343
+ }
344
+ /**
345
+ * Strip the `mcp__<server>__` prefix from a tool name to get the bare
346
+ * tool identity. Native tools pass through unchanged. Same convention
347
+ * as `tool_call` events' `tool_name` field — keeps wire format
348
+ * symmetric so backend dashboards don't need to special-case MCP keys.
349
+ *
350
+ * `"mcp__browser-devtools__bdt_navigation_go-to"` → `"bdt_navigation_go-to"`
351
+ * `"Edit"` → `"Edit"`
352
+ * `"mcp__broken"` → `"mcp__broken"` (unchanged)
353
+ */
354
+ function bareTool(toolName) {
355
+ if (!toolName.startsWith("mcp__")) {
356
+ return toolName;
357
+ }
358
+ const rest = toolName.slice("mcp__".length);
359
+ const sep = rest.indexOf("__");
360
+ if (sep <= 0) {
361
+ return toolName;
362
+ }
363
+ return rest.slice(sep + 2);
364
+ }
365
+ /**
366
+ * Apply an `update(slot)` mutation to every bucket this tool_use belongs
367
+ * to. Always updates `tools[bareName]`. Conditionally updates server /
368
+ * bash binary / skill / sub-agent buckets when the corresponding
369
+ * sub-classification is present on the pending entry.
370
+ *
371
+ * Centralizes the multi-bucket parallel write so the same tool_use's
372
+ * tokens / cost / count / errors stay in lockstep across views — the
373
+ * sum of per-server values equals the per-tool sums for that server's
374
+ * tools, etc.
375
+ */
376
+ function applyToBuckets(pending, update, buckets) {
377
+ const bare = bareTool(pending.tool_name);
378
+ const slotTool = buckets.tools[bare] ?? emptyToolUsage();
379
+ update(slotTool);
380
+ buckets.tools[bare] = slotTool;
381
+ const server = extractMcpServer(pending.tool_name);
382
+ if (server !== undefined) {
383
+ const slot = buckets.mcpServers[server] ?? emptyToolUsage();
384
+ update(slot);
385
+ buckets.mcpServers[server] = slot;
386
+ }
387
+ if (pending.bash_binary !== undefined) {
388
+ const slot = buckets.bashBinaries[pending.bash_binary] ?? emptyToolUsage();
389
+ update(slot);
390
+ buckets.bashBinaries[pending.bash_binary] = slot;
391
+ }
392
+ if (pending.skill_name !== undefined) {
393
+ const slot = buckets.skills[pending.skill_name] ?? emptyToolUsage();
394
+ update(slot);
395
+ buckets.skills[pending.skill_name] = slot;
396
+ }
397
+ if (pending.sub_agent_type !== undefined) {
398
+ const slot = buckets.subAgents[pending.sub_agent_type] ?? emptyToolUsage();
399
+ update(slot);
400
+ buckets.subAgents[pending.sub_agent_type] = slot;
401
+ }
402
+ }
403
+ /**
404
+ * Byte length of a content block for attribution purposes.
405
+ * - tool_use → JSON.stringify(input)
406
+ * - text → utf-8 bytes of `text`
407
+ * - any other (thinking, etc.) → JSON.stringify(block) as a generic fallback
408
+ *
409
+ * This is the unit of share computation for output-side attribution: a
410
+ * tool_use's share at its emitter assistant message N1 = its bytes /
411
+ * total bytes across all blocks of N1's content. Non-tool_use blocks
412
+ * (text, thinking) get their share routed to `unattributed_*`.
413
+ */
414
+ function blockBytes(block) {
415
+ if (block.type === "tool_use") {
416
+ return Buffer.byteLength(JSON.stringify(block.input ?? {}), "utf-8");
417
+ }
418
+ if (block.type === "text" && typeof block.text === "string") {
419
+ return Buffer.byteLength(block.text, "utf-8");
420
+ }
421
+ // thinking blocks etc. — fall back to JSON.stringify so their bytes
422
+ // still feed the denominator (otherwise we'd over-attribute to tools).
423
+ return Buffer.byteLength(JSON.stringify(block), "utf-8");
424
+ }
425
+ /**
426
+ * Extract concatenated text from a user message's content blocks. Used
427
+ * locally inside projection for keyword regex matching — never persisted
428
+ * or emitted (privacy fence). Returns "" for non-user / non-text msgs.
429
+ */
430
+ function extractUserMsgText(line) {
431
+ if (line.type !== "user" || line.message === undefined) {
432
+ return "";
433
+ }
434
+ const content = line.message.content;
435
+ if (typeof content === "string") {
436
+ return content;
437
+ }
438
+ if (!Array.isArray(content)) {
439
+ return "";
440
+ }
441
+ const parts = [];
442
+ for (const blk of content) {
443
+ if (blk === null || typeof blk !== "object") {
444
+ continue;
445
+ }
446
+ const block = blk;
447
+ if (block.type === "text" && typeof block.text === "string") {
448
+ parts.push(block.text);
449
+ }
450
+ }
451
+ return parts.join(" ");
452
+ }
453
+ /** Lazy-init the per-category breakdown slot. */
454
+ function emptyCategoryStats() {
455
+ return { turns: 0, turns_with_edit: 0, turns_with_retry: 0, total_retries: 0, cost_usd: 0 };
456
+ }
457
+ /**
458
+ * Deterministic turn id — `sha256(session_id|turn_index|start_time)`
459
+ * sliced to 16 hex chars. Re-emits of the same turn produce the same
460
+ * id, so backend LWW per `(session_id, turn_id)` is well-defined.
461
+ */
462
+ /**
463
+ * Deterministic per-step identifier — `sha256(turn_id|step_index|start_time)`
464
+ * sliced to 16 hex chars. Same property as {@link deriveTurnId}: same
465
+ * inputs always map to the same id, so backend LWW per `(session_id,
466
+ * step_id)` is well-defined and re-emits are idempotent.
467
+ */
468
+ function deriveStepId(turnId, stepIndex, startTime) {
469
+ return (0, node_crypto_1.createHash)("sha256")
470
+ .update(`${turnId}|${stepIndex}|${startTime}`)
471
+ .digest("hex")
472
+ .slice(0, 16);
473
+ }
474
+ function deriveTurnId(sessionId, turnIndex, startTime) {
475
+ return (0, node_crypto_1.createHash)("sha256")
476
+ .update(`${sessionId}|${turnIndex}|${startTime}`)
477
+ .digest("hex")
478
+ .slice(0, 16);
479
+ }
480
+ /**
481
+ * Format a 32-char hex string into UUID-shaped layout (8-4-4-4-12). NOT
482
+ * RFC 4122-compliant (no version/variant bits) but matches the regex
483
+ * shape collectors expect for `Event.id`. Pure presentation — collisions
484
+ * impossible because the source hash space is the same 32 hex chars.
485
+ */
486
+ function formatHexAsUuid(hex32) {
487
+ return `${hex32.slice(0, 8)}-${hex32.slice(8, 12)}-${hex32.slice(12, 16)}-`
488
+ + `${hex32.slice(16, 20)}-${hex32.slice(20, 32)}`;
489
+ }
490
+ /**
491
+ * Deterministic Event.id for `session_analytics` wire records.
492
+ * `sha256("session_analytics:" + session_id + ":" + offset)`, formatted as
493
+ * UUID. Re-emits of the same `(session_id, offset)` produce the same id —
494
+ * backend treats them as updates to the same logical checkpoint:
495
+ * - First Stop @ offset=100 (`is_final=false`) → row inserted
496
+ * - SessionEnd @ offset=100 (carve-out, `is_final=true`) → ON CONFLICT update
497
+ * - Worker re-projects same slice after crash → ON CONFLICT update
498
+ * `snapshot_at` is intentionally NOT in the id — it's metadata for
499
+ * tiebreaking on collision (`UPDATE ... WHERE EXCLUDED.snapshot_at >=
500
+ * stored.snapshot_at`). This keeps `(session_id, id)` as a uniform dedup
501
+ * key across all analytics event types.
502
+ */
503
+ function deriveSessionAnalyticsEventId(sessionId, offset) {
504
+ const hex = (0, node_crypto_1.createHash)("sha256")
505
+ .update(`session_analytics:${sessionId}:${offset}`)
506
+ .digest("hex");
507
+ return formatHexAsUuid(hex.slice(0, 32));
508
+ }
509
+ /**
510
+ * Deterministic Event.id for `session_turn_analytics` wire records.
511
+ * Derives from the turn's deterministic `turn_id` namespaced under the
512
+ * session — re-emits of the same turn produce the same Event.id.
513
+ */
514
+ function deriveTurnEventId(sessionId, turnId) {
515
+ const hex = (0, node_crypto_1.createHash)("sha256")
516
+ .update(`session_turn_analytics:${sessionId}:${turnId}`)
517
+ .digest("hex");
518
+ return formatHexAsUuid(hex.slice(0, 32));
519
+ }
520
+ /**
521
+ * Deterministic Event.id for `session_turn_step_analytics` wire records.
522
+ * Same pattern as {@link deriveTurnEventId} — derives from the step's
523
+ * deterministic `step_id` namespaced under the session. Re-emits idempotent.
524
+ */
525
+ function deriveStepEventId(sessionId, stepId) {
526
+ const hex = (0, node_crypto_1.createHash)("sha256")
527
+ .update(`session_turn_step_analytics:${sessionId}:${stepId}`)
528
+ .digest("hex");
529
+ return formatHexAsUuid(hex.slice(0, 32));
530
+ }
531
+ /**
532
+ * Open a fresh turn from the human-user prompt that started it. Computes
533
+ * keyword flags eagerly from the user-msg text so we don't need to
534
+ * persist content. Caller threads this into `currentTurn` state.
535
+ *
536
+ * Per-turn aggregator maps are seeded empty; the projection walk
537
+ * mirrors every session-level update onto them (see `applyToBucketsAndTurn`
538
+ * + the inline `if (currentTurn !== null)` blocks in the assistant /
539
+ * user / tool_use / tool_result branches).
540
+ */
541
+ function openTurn(userText, turnIndex, startTime, triggeredBy = "user_msg") {
542
+ const kw = (0, classifier_1.extractUserMsgKeywords)(userText);
543
+ return {
544
+ turn_index: turnIndex,
545
+ start_time: startTime,
546
+ triggered_by: triggeredBy,
547
+ last_activity_time: startTime,
548
+ kw_debug: kw.debug,
549
+ kw_feature: kw.feature,
550
+ kw_refactor: kw.refactor,
551
+ kw_brainstorm: kw.brainstorm,
552
+ kw_research: kw.research,
553
+ kw_file_pattern: kw.file_pattern,
554
+ kw_script_pattern: kw.script_pattern,
555
+ kw_url: kw.url,
556
+ has_edit: false, has_read: false, has_bash: false,
557
+ has_task: false, has_search: false, has_mcp: false,
558
+ has_skill: false, has_plan: false,
559
+ bash_test: false, bash_build: false, bash_install: false, bash_git: false,
560
+ saw_edit_pending_bash: false,
561
+ saw_bash_after_edit: false,
562
+ retries: 0,
563
+ assistant_messages: 0,
564
+ tool_calls: 0,
565
+ was_interrupted: false,
566
+ input_tokens: 0,
567
+ output_tokens: 0,
568
+ cache_creation_tokens: 0,
569
+ cache_read_tokens: 0,
570
+ cost_usd: 0,
571
+ tools: {},
572
+ mcp_servers: {},
573
+ bash_binaries: {},
574
+ bash_subcommands: {},
575
+ skills: {},
576
+ sub_agents: {},
577
+ models: {},
578
+ languages: {},
579
+ uses_sub_agent: false,
580
+ uses_skill: false,
581
+ uses_mcp: false,
582
+ uses_web_search: false,
583
+ uses_web_fetch: false,
584
+ file_change_counts: {},
585
+ lines_added: 0,
586
+ lines_removed: 0,
587
+ tool_errors: 0,
588
+ tool_error_categories: {},
589
+ context_tokens_samples: [],
590
+ context_tokens_latest: 0,
591
+ context_tokens_peak: 0,
592
+ process_errors: {},
593
+ // Per-step bookkeeping (cross-slice safe). `current_step` opens at
594
+ // the first assistant message of this turn; closes when the next
595
+ // assistant arrives (pushed to `completed_steps`) or when the turn
596
+ // closes. `next_step_index` resets per turn (steps are turn-scoped).
597
+ completed_steps: [],
598
+ next_step_index: 1,
599
+ };
600
+ }
601
+ /**
602
+ * Deep-copy a {@link CurrentTurnState} so the projection's mutating walk
603
+ * does not bleed into the caller's `state.internal.current_turn`. The
604
+ * shallow-copy `{ ...prior }` we used to do shared the inner maps + the
605
+ * `completed_steps` array (and that array gets `.push()`'d during the
606
+ * walk), which would otherwise mutate the persisted-state snapshot
607
+ * mid-emit. Defensive — every persist after this re-derives state, so
608
+ * the upstream effect never reaches disk; this just keeps the data flow
609
+ * pure so future code paths cannot reintroduce the pitfall.
610
+ *
611
+ * Maps / arrays at depth 1 are cloned. ToolUsage / ModelUsage / file_change
612
+ * map values themselves are shallow-copied (their fields are primitives).
613
+ * `current_step` and the entries of `completed_steps` are deep-copied via
614
+ * {@link cloneStep}.
615
+ */
616
+ function cloneCurrentTurn(prior) {
617
+ return {
618
+ ...prior,
619
+ tools: cloneToolUsageMap(prior.tools),
620
+ mcp_servers: cloneToolUsageMap(prior.mcp_servers),
621
+ bash_binaries: cloneToolUsageMap(prior.bash_binaries),
622
+ bash_subcommands: { ...prior.bash_subcommands },
623
+ skills: cloneToolUsageMap(prior.skills),
624
+ sub_agents: cloneToolUsageMap(prior.sub_agents),
625
+ models: cloneModelUsageMap(prior.models),
626
+ languages: { ...prior.languages },
627
+ file_change_counts: { ...prior.file_change_counts },
628
+ tool_error_categories: { ...prior.tool_error_categories },
629
+ context_tokens_samples: [...prior.context_tokens_samples],
630
+ process_errors: cloneProcessErrors(prior.process_errors),
631
+ completed_steps: prior.completed_steps.map(cloneStep),
632
+ current_step: prior.current_step !== undefined ? cloneStep(prior.current_step) : undefined,
633
+ };
634
+ }
635
+ function cloneStep(prior) {
636
+ return {
637
+ ...prior,
638
+ tools: cloneToolUsageMap(prior.tools),
639
+ mcp_servers: cloneToolUsageMap(prior.mcp_servers),
640
+ bash_binaries: cloneToolUsageMap(prior.bash_binaries),
641
+ bash_subcommands: { ...prior.bash_subcommands },
642
+ skills: cloneToolUsageMap(prior.skills),
643
+ sub_agents: cloneToolUsageMap(prior.sub_agents),
644
+ models: cloneModelUsageMap(prior.models),
645
+ languages: { ...prior.languages },
646
+ file_change_counts: { ...prior.file_change_counts },
647
+ tool_error_categories: { ...prior.tool_error_categories },
648
+ process_errors: cloneProcessErrors(prior.process_errors),
649
+ };
650
+ }
651
+ function cloneToolUsageMap(map) {
652
+ const out = {};
653
+ for (const [k, v] of Object.entries(map)) {
654
+ out[k] = { ...v };
655
+ }
656
+ return out;
657
+ }
658
+ function cloneModelUsageMap(map) {
659
+ const out = {};
660
+ for (const [k, v] of Object.entries(map)) {
661
+ out[k] = { ...v };
662
+ }
663
+ return out;
664
+ }
665
+ function cloneProcessErrors(map) {
666
+ const out = {};
667
+ for (const [k, v] of Object.entries(map)) {
668
+ out[k] = { ...v };
669
+ }
670
+ return out;
671
+ }
672
+ /**
673
+ * Build a fresh in-flight per-step accumulator. Mirrors `openTurn` shape
674
+ * but step-scoped: every aggregator starts at zero / empty. Caller (the
675
+ * assistant-branch in the projection walk) records `step_index` and
676
+ * `start_time` from the opening assistant message.
677
+ */
678
+ function openStep(stepIndex, startTime) {
679
+ return {
680
+ step_index: stepIndex,
681
+ start_time: startTime,
682
+ last_activity_time: startTime,
683
+ tool_calls: 0,
684
+ was_interrupted: false,
685
+ input_tokens: 0,
686
+ output_tokens: 0,
687
+ cache_creation_tokens: 0,
688
+ cache_read_tokens: 0,
689
+ cost_usd: 0,
690
+ tools: {},
691
+ mcp_servers: {},
692
+ bash_binaries: {},
693
+ bash_subcommands: {},
694
+ skills: {},
695
+ sub_agents: {},
696
+ models: {},
697
+ languages: {},
698
+ uses_sub_agent: false,
699
+ uses_skill: false,
700
+ uses_mcp: false,
701
+ uses_web_search: false,
702
+ uses_web_fetch: false,
703
+ file_change_counts: {},
704
+ lines_added: 0,
705
+ lines_removed: 0,
706
+ tool_errors: 0,
707
+ tool_error_categories: {},
708
+ process_errors: {},
709
+ };
710
+ }
711
+ /**
712
+ * Apply a {@link CloseTurnResult}'s `breakdown_delta` onto a category-
713
+ * breakdown map. Always WRITES A FRESH SLOT — never mutates the existing
714
+ * one in place. This decouples the post-merge mutation in emit.ts's
715
+ * finalize block from the slot-reference assumptions of any future
716
+ * `mergeCategoryBreakdown` refactor.
717
+ */
718
+ function applyBreakdownDelta(breakdown, r) {
719
+ const slot = breakdown[r.category] ?? emptyCategoryStats();
720
+ breakdown[r.category] = {
721
+ turns: slot.turns + r.breakdown_delta.turns_inc,
722
+ turns_with_edit: slot.turns_with_edit + r.breakdown_delta.turns_with_edit_inc,
723
+ turns_with_retry: slot.turns_with_retry + r.turns_with_retry_inc,
724
+ total_retries: slot.total_retries + r.breakdown_delta.total_retries_inc,
725
+ cost_usd: slot.cost_usd + r.breakdown_delta.cost_usd_inc,
726
+ };
727
+ }
728
+ /**
729
+ * Close a turn — finalizes the per-turn record, runs the classifier,
730
+ * and produces a fully-built `SessionTurnAnalytics` for the wire plus
731
+ * the per-category breakdown delta the caller should apply.
732
+ *
733
+ * Pure function — does not mutate any input. `opts.endTime` is the
734
+ * closing event timestamp (next human-user msg, or session_end when
735
+ * finalizing). `opts.endReason` records which boundary fired so backend
736
+ * can distinguish normal turn ends from session-end forced closes.
737
+ */
738
+ function closeTurn(turn, opts) {
739
+ const { endTime, endReason, sessionId, projectName, transcriptSource } = opts;
740
+ const category = (0, classifier_1.classifyTurn)({
741
+ user_msg_keywords: {
742
+ debug: turn.kw_debug, feature: turn.kw_feature, refactor: turn.kw_refactor,
743
+ brainstorm: turn.kw_brainstorm, research: turn.kw_research,
744
+ file_pattern: turn.kw_file_pattern, script_pattern: turn.kw_script_pattern, url: turn.kw_url,
745
+ },
746
+ tool_buckets: {
747
+ edit: turn.has_edit, read: turn.has_read, bash: turn.has_bash,
748
+ task: turn.has_task, search: turn.has_search, mcp: turn.has_mcp,
749
+ skill: turn.has_skill, plan: turn.has_plan,
750
+ },
751
+ bash_cmd_flags: {
752
+ test: turn.bash_test, build: turn.bash_build,
753
+ install: turn.bash_install, git: turn.bash_git,
754
+ },
755
+ });
756
+ const turnHadRetry = turn.retries > 0;
757
+ // `was_one_shot` means "agent got it right on the first attempt." A
758
+ // host_inject turn (verify-gate retry continuation, etc.) is BY
759
+ // DEFINITION not first-try work — it exists only because a prior turn
760
+ // failed. Gate one_shot on user_msg trigger so retry turns never
761
+ // inflate session-level "first-try success rate" dashboards.
762
+ const oneShot = turn.has_edit && !turnHadRetry && turn.triggered_by === "user_msg";
763
+ // Time math — duration in ms (default unit). Fallbacks keep the
764
+ // record well-formed even if either timestamp is missing.
765
+ const startMs = Date.parse(turn.start_time);
766
+ const endMs = Date.parse(endTime);
767
+ const startValid = !Number.isNaN(startMs);
768
+ const endValid = !Number.isNaN(endMs);
769
+ const duration = startValid && endValid ? Math.max(0, endMs - startMs) : 0;
770
+ const startHour = startValid ? new Date(startMs).getHours() : 0;
771
+ const endHour = endValid ? new Date(endMs).getHours() : 0;
772
+ // Per-turn files_changed — sort desc by change_count, asc by path
773
+ // tie-break (matches session-level hot_files determinism).
774
+ const filesChanged = Object.entries(turn.file_change_counts)
775
+ .map(([path, change_count]) => ({ path, change_count }))
776
+ .sort((a, b) => {
777
+ if (b.change_count !== a.change_count) {
778
+ return b.change_count - a.change_count;
779
+ }
780
+ return a.path.localeCompare(b.path);
781
+ });
782
+ const turnEvent = {
783
+ session_id: sessionId,
784
+ project_name: projectName,
785
+ schema_version: types_1.SCHEMA_VERSION,
786
+ transcript_source: transcriptSource,
787
+ turn_index: turn.turn_index,
788
+ turn_id: deriveTurnId(sessionId, turn.turn_index, turn.start_time),
789
+ triggered_by: turn.triggered_by,
790
+ start_time: turn.start_time,
791
+ end_time: endTime,
792
+ end_reason: endReason,
793
+ start_hour: startHour,
794
+ end_hour: endHour,
795
+ duration,
796
+ category,
797
+ was_one_shot: oneShot,
798
+ was_interrupted: turn.was_interrupted,
799
+ assistant_messages: turn.assistant_messages,
800
+ tool_calls: turn.tool_calls,
801
+ retries: turn.retries,
802
+ // Logical groups
803
+ usage: {
804
+ input_tokens: turn.input_tokens,
805
+ output_tokens: turn.output_tokens,
806
+ cache_creation_tokens: turn.cache_creation_tokens,
807
+ cache_read_tokens: turn.cache_read_tokens,
808
+ cost_usd: turn.cost_usd,
809
+ },
810
+ // Per-tool breakdowns (top-level)
811
+ tools: turn.tools,
812
+ mcp_servers: turn.mcp_servers,
813
+ bash_binaries: turn.bash_binaries,
814
+ skills: turn.skills,
815
+ sub_agents: turn.sub_agents,
816
+ models: turn.models,
817
+ tool_meta: {
818
+ ...(Object.keys(turn.bash_subcommands).length > 0 ? { bash_subcommands: turn.bash_subcommands } : {}),
819
+ uses_sub_agent: turn.uses_sub_agent,
820
+ uses_skill: turn.uses_skill,
821
+ uses_mcp: turn.uses_mcp,
822
+ uses_web_search: turn.uses_web_search,
823
+ uses_web_fetch: turn.uses_web_fetch,
824
+ },
825
+ code_changes: {
826
+ files_modified: filesChanged.length,
827
+ lines_added: turn.lines_added,
828
+ lines_removed: turn.lines_removed,
829
+ hot_files: filesChanged,
830
+ languages: turn.languages,
831
+ },
832
+ errors: {
833
+ tool_errors_total: turn.tool_errors,
834
+ tool_error_categories: turn.tool_error_categories,
835
+ user_interruptions: turn.was_interrupted ? 1 : 0,
836
+ },
837
+ process_errors: {
838
+ has: Object.keys(turn.process_errors).length > 0,
839
+ items: turn.process_errors,
840
+ },
841
+ context_tokens_samples: turn.context_tokens_samples,
842
+ context_tokens_latest: turn.context_tokens_latest,
843
+ context_tokens_peak: turn.context_tokens_peak,
844
+ };
845
+ // ── Build per-step events ────────────────────────────────────────────
846
+ // Close the in-flight current_step (if any) by appending it to
847
+ // completed_steps. closeTurn is a pure function — we operate on a local
848
+ // copy so the input `turn` is not mutated. Callers that read
849
+ // `turn.completed_steps` post-close should be aware: those steps live
850
+ // inside the turn's snapshot and are consumed by this build.
851
+ const allSteps = turn.current_step !== undefined
852
+ ? [...turn.completed_steps, turn.current_step]
853
+ : [...turn.completed_steps];
854
+ const turnId = turnEvent.turn_id;
855
+ const stepEvents = allSteps.map((step, idx) => {
856
+ const isLast = idx === allSteps.length - 1;
857
+ const stepStartMs = Date.parse(step.start_time);
858
+ const stepEndStr = step.last_activity_time !== "" ? step.last_activity_time : step.start_time;
859
+ const stepEndMs = Date.parse(stepEndStr);
860
+ const stepDuration = !Number.isNaN(stepStartMs) && !Number.isNaN(stepEndMs)
861
+ ? Math.max(0, stepEndMs - stepStartMs)
862
+ : 0;
863
+ const stepStartHour = !Number.isNaN(stepStartMs) ? new Date(stepStartMs).getHours() : 0;
864
+ const stepEndHour = !Number.isNaN(stepEndMs) ? new Date(stepEndMs).getHours() : 0;
865
+ // Per-step files_changed — same sort rules as turn-level.
866
+ const stepFiles = Object.entries(step.file_change_counts)
867
+ .map(([path, change_count]) => ({ path, change_count }))
868
+ .sort((a, b) => {
869
+ if (b.change_count !== a.change_count) {
870
+ return b.change_count - a.change_count;
871
+ }
872
+ return a.path.localeCompare(b.path);
873
+ });
874
+ const stepEvent = {
875
+ session_id: sessionId,
876
+ project_name: projectName,
877
+ schema_version: types_1.SCHEMA_VERSION,
878
+ transcript_source: transcriptSource,
879
+ turn_id: turnId,
880
+ turn_index: turn.turn_index,
881
+ triggered_by: turn.triggered_by,
882
+ step_id: deriveStepId(turnId, step.step_index, step.start_time),
883
+ step_index: step.step_index,
884
+ start_time: step.start_time,
885
+ end_time: stepEndStr,
886
+ start_hour: stepStartHour,
887
+ end_hour: stepEndHour,
888
+ duration: stepDuration,
889
+ is_last_step: isLast,
890
+ ...(isLast ? {
891
+ turn_end_reason: endReason,
892
+ turn_end_time: endTime,
893
+ turn_duration: duration,
894
+ turn_category: category,
895
+ turn_was_one_shot: oneShot,
896
+ turn_was_interrupted: turn.was_interrupted,
897
+ } : {}),
898
+ assistant_messages: 1,
899
+ tool_calls: step.tool_calls,
900
+ was_interrupted: step.was_interrupted,
901
+ // Logical groups
902
+ usage: {
903
+ input_tokens: step.input_tokens,
904
+ output_tokens: step.output_tokens,
905
+ cache_creation_tokens: step.cache_creation_tokens,
906
+ cache_read_tokens: step.cache_read_tokens,
907
+ cost_usd: step.cost_usd,
908
+ },
909
+ // Per-tool breakdowns (top-level)
910
+ tools: step.tools,
911
+ mcp_servers: step.mcp_servers,
912
+ bash_binaries: step.bash_binaries,
913
+ skills: step.skills,
914
+ sub_agents: step.sub_agents,
915
+ models: step.models,
916
+ tool_meta: {
917
+ ...(Object.keys(step.bash_subcommands).length > 0 ? { bash_subcommands: step.bash_subcommands } : {}),
918
+ uses_sub_agent: step.uses_sub_agent,
919
+ uses_skill: step.uses_skill,
920
+ uses_mcp: step.uses_mcp,
921
+ uses_web_search: step.uses_web_search,
922
+ uses_web_fetch: step.uses_web_fetch,
923
+ },
924
+ code_changes: {
925
+ files_modified: stepFiles.length,
926
+ lines_added: step.lines_added,
927
+ lines_removed: step.lines_removed,
928
+ hot_files: stepFiles,
929
+ languages: step.languages,
930
+ },
931
+ errors: {
932
+ tool_errors_total: step.tool_errors,
933
+ tool_error_categories: step.tool_error_categories,
934
+ user_interruptions: step.was_interrupted ? 1 : 0,
935
+ },
936
+ process_errors: {
937
+ has: Object.keys(step.process_errors).length > 0,
938
+ items: step.process_errors,
939
+ },
940
+ ...(step.context_tokens !== undefined ? { context_tokens: step.context_tokens } : {}),
941
+ };
942
+ return stepEvent;
943
+ });
944
+ return {
945
+ category,
946
+ turns_with_retry_inc: turnHadRetry ? 1 : 0,
947
+ one_shot_inc: oneShot ? 1 : 0,
948
+ breakdown_delta: {
949
+ turns_inc: 1,
950
+ turns_with_edit_inc: turn.has_edit ? 1 : 0,
951
+ total_retries_inc: turn.retries,
952
+ cost_usd_inc: turn.cost_usd,
953
+ },
954
+ turn_event: turnEvent,
955
+ step_events: stepEvents,
956
+ };
957
+ }
958
+ /**
959
+ * Wrapper around {@link applyToBuckets} that ALSO applies the same
960
+ * mutation onto the per-turn aggregator buckets (when a turn is open).
961
+ * Keeps session-level and per-turn views in lockstep so the per-turn
962
+ * sums always reconcile against the session-level rollup.
963
+ */
964
+ function applyToBucketsAndTurn(pending, update, sliceBuckets, turn) {
965
+ applyToBuckets(pending, update, sliceBuckets);
966
+ if (turn !== null) {
967
+ applyToBuckets(pending, update, {
968
+ tools: turn.tools,
969
+ mcpServers: turn.mcp_servers,
970
+ bashBinaries: turn.bash_binaries,
971
+ skills: turn.skills,
972
+ subAgents: turn.sub_agents,
973
+ });
974
+ // Mirror onto the in-flight step too, when one is open. Step
975
+ // attribution follows the same emit-vs-consume rule as turn —
976
+ // whichever step is current at the mutation point owns the work
977
+ // (output-side at emit; input-side credit to the step that
978
+ // consumes the result, even if that's the next step).
979
+ if (turn.current_step !== undefined) {
980
+ applyToBuckets(pending, update, {
981
+ tools: turn.current_step.tools,
982
+ mcpServers: turn.current_step.mcp_servers,
983
+ bashBinaries: turn.current_step.bash_binaries,
984
+ skills: turn.current_step.skills,
985
+ subAgents: turn.current_step.sub_agents,
986
+ });
987
+ }
988
+ }
989
+ }
990
+ // ─────────────────────────────────────────────────────────────────────────
991
+ // Main projection
992
+ // ─────────────────────────────────────────────────────────────────────────
993
+ function projectDelta(input) {
994
+ const { lines, startingTurnIndex, sessionId, projectName, transcriptSource } = input;
995
+ // Counters
996
+ let userTurns = 0;
997
+ // Per-API-request collector — one APIRequestAnalytics body per assistant
998
+ // line we project (success + failure). Drained by emit alongside turn /
999
+ // step events. Body-only struct; emit wraps with the Event envelope.
1000
+ const apiRequestEvents = [];
1001
+ let assistantResponseCount = 0;
1002
+ let inputTokens = 0;
1003
+ let outputTokens = 0;
1004
+ let cacheCreationTokens = 0;
1005
+ let cacheReadTokens = 0;
1006
+ let costUsd = 0;
1007
+ let toolErrorsTotal = 0;
1008
+ let userInterruptions = 0;
1009
+ let linesAdded = 0;
1010
+ let linesRemoved = 0;
1011
+ // Maps & arrays
1012
+ // (tool_counts removed — derive from `tools[*].count`; bash_binaries
1013
+ // is a ToolUsage map; bash_subcommands stays count-only.)
1014
+ const bashSubcommands = {};
1015
+ const languages = {};
1016
+ const toolErrorCategories = {};
1017
+ const filePathChangeCounts = {};
1018
+ const distinctFilePaths = new Set();
1019
+ // User-activity histograms. Counter-map merge rule (per-key sum) handles
1020
+ // cross-slice aggregation. No per-msg arrays — privacy-clean and cheaper
1021
+ // to merge across long sessions.
1022
+ const responseTimeBuckets = {};
1023
+ const messagesByHour = {};
1024
+ const messagesByDate = {};
1025
+ const messagesByWeekday = {};
1026
+ // user_messages aggregate — count + size (bytes of human-typed text
1027
+ // content) + approximated_tokens (recomputed at delta build).
1028
+ let userMessagesCount = 0;
1029
+ let userMessagesSize = 0;
1030
+ const models = {};
1031
+ // Per-tool attribution state.
1032
+ // `tools` is the per-key delta of tool→usage; `pendingToolUses` is the
1033
+ // in-flight cross-slice state machine (see PendingToolUse type docs).
1034
+ const tools = {};
1035
+ // MCP server roll-up — per-server aggregate of every numeric ToolUsage
1036
+ // field across all tools belonging to that server. Updated alongside
1037
+ // `tools` in every attribution path (output share, input share, count,
1038
+ // errors). Server name extracted from `mcp__<server>__<tool>` prefix.
1039
+ const mcpServers = {};
1040
+ // Per-Bash-binary roll-up — keyed by the first token of each Bash
1041
+ // command body (e.g. "git", "npm", "pytest", "docker").
1042
+ const bashBinariesRich = {};
1043
+ // Per-skill roll-up — keyed by `Skill` tool's `input.skill`.
1044
+ const skills = {};
1045
+ // Per-sub-agent roll-up — keyed by `Agent` / `Task` tool's
1046
+ // `input.subagent_type`.
1047
+ const subAgents = {};
1048
+ const pendingToolUses = {
1049
+ ...(input.priorPendingToolUses ?? {}),
1050
+ };
1051
+ // Unattributed residuals — text content + history-reading + cache.
1052
+ let unattributedInputTokens = 0;
1053
+ let unattributedOutputTokens = 0;
1054
+ let unattributedCacheCreationTokens = 0;
1055
+ let unattributedCacheReadTokens = 0;
1056
+ let unattributedCostUsd = 0;
1057
+ // Per-tool token attribution was previously a two-phase state machine
1058
+ // (emitter / resulted / consumer-applies). The current pipeline is a
1059
+ // simple byte counter — `tool_result` handler writes `output_size`
1060
+ // directly into the bucket and removes the pending entry. The locals
1061
+ // below are kept (always empty / 0) so call sites that still touch
1062
+ // them are harmless; remove together when the call sites are cleaned.
1063
+ let resultedToolUseIds = [];
1064
+ let priorUserMsgTextBytes = 0;
1065
+ // Boolean flags
1066
+ let usesSubAgent = false;
1067
+ let usesSkill = false;
1068
+ let usesMcp = false;
1069
+ let usesWebSearch = false;
1070
+ let usesWebFetch = false;
1071
+ // Bounds
1072
+ let firstTimestamp = "";
1073
+ let lastTimestamp = "";
1074
+ // Context-tokens distribution
1075
+ const contextTokensBuckets = {};
1076
+ let contextTokensLatest = 0;
1077
+ let contextTokensPeak = 0;
1078
+ let hasAssistantWithUsage = false;
1079
+ // Per-projection error capture map. Recurring errors collapse into a
1080
+ // single entry whose `count` increments — same as the merge step does
1081
+ // across deltas. Surfaces parse / format / projector bugs without
1082
+ // flooding the wire.
1083
+ const processErrors = {};
1084
+ // For response-time calculation. Seeded from prior slice (if any) so the
1085
+ // gap from prior-slice's last assistant to this-slice's first human user
1086
+ // is captured.
1087
+ let lastAssistantMs = input.priorLastAssistantTsMs ?? null;
1088
+ let assistantTurnIndex = startingTurnIndex - 1; // bumped on each assistant message
1089
+ // Anthropic API msg_id dedup. Claude Code's transcript writer occasionally
1090
+ // persists the SAME assistant API response across multiple JSONL lines
1091
+ // (different uuids, identical message.id + requestId + usage). Seeded from
1092
+ // prior slices so a duplicate that crosses a slice boundary is still caught.
1093
+ // Reported back via DeltaInternal.seen_assistant_message_ids.
1094
+ const seenAssistantMessageIds = new Set(input.priorSeenAssistantMessageIds ?? []);
1095
+ const newAssistantMessageIdsThisSlice = [];
1096
+ // Idle attribution: walking the timeline, the gap BEFORE a human-user
1097
+ // message is "idle" (user typing/thinking/away). Every other gap is
1098
+ // "active" (model + tools + agent loop). Seed `priorTsMs` from the prior
1099
+ // slice's last message so cross-slice idle is captured correctly. The
1100
+ // raw idle gap can be larger than the project's wall-clock if state was
1101
+ // reset; the merge step clamps `active_minutes ≥ 0`.
1102
+ let priorTsMs = input.priorLastActivityTsMs ?? null;
1103
+ let idleMsInSlice = 0;
1104
+ // Per-turn classifier state. `currentTurn` is null between turns; opens
1105
+ // on a human-user message and closes when the NEXT human-user msg
1106
+ // arrives (or session finalization). Cross-slice continuity comes from
1107
+ // `priorCurrentTurn` — a turn straddling a slice boundary resumes here.
1108
+ const categoryBreakdown = {};
1109
+ let turnsWithRetryInSlice = 0;
1110
+ let oneShotTurnsInSlice = 0;
1111
+ let currentTurn = input.priorCurrentTurn !== undefined
1112
+ ? cloneCurrentTurn(input.priorCurrentTurn)
1113
+ : null;
1114
+ // Monotonic per-session turn index — seed from prior slice's persisted
1115
+ // counter (state.internal.next_turn_index), default 1 on first ever
1116
+ // slice. Each call to openTurn assigns this then increments.
1117
+ let nextTurnIndex = input.priorNextTurnIndex ?? 1;
1118
+ // SessionTurnAnalytics records that closed during this slice. Reported
1119
+ // on the delta; merge appends them to state.internal.pending_turn_events.
1120
+ const completedTurns = [];
1121
+ // SessionTurnStepAnalytics records belonging to closed turns — collected
1122
+ // alongside completedTurns from each closeTurn() result. Same merge path:
1123
+ // appended to state.internal.pending_step_events; emit drains.
1124
+ const completedSteps = [];
1125
+ let lineIndex = -1;
1126
+ for (const line of lines) {
1127
+ lineIndex += 1;
1128
+ // Per-line try/catch — an unexpected transcript shape on one line
1129
+ // must never abort the rest of the slice. Failures are recorded into
1130
+ // `processErrors` (deduped by signature) and surfaced via
1131
+ // `SessionAnalytics.process_errors` so we get visibility into format
1132
+ // drift without silent loss.
1133
+ try {
1134
+ const tsString = line.timestamp;
1135
+ const tsMillis = tsMs(tsString);
1136
+ // Track bounds (first / last timestamp seen — first only set once)
1137
+ if (typeof tsString === "string" && tsString.length > 0) {
1138
+ if (firstTimestamp === "") {
1139
+ firstTimestamp = tsString;
1140
+ }
1141
+ // last_timestamp is updated per Bounds-latest rule via merge;
1142
+ // we just record the last one we see in this delta.
1143
+ lastTimestamp = tsString;
1144
+ }
1145
+ // Idle attribution: gap-before-human-user counts as user-idle,
1146
+ // every other gap is active (model + tools + agent loop). The
1147
+ // priorTsMs cursor seeds from the prior slice's last_activity_time
1148
+ // so cross-slice gaps land on the correct side.
1149
+ if (tsMillis !== null) {
1150
+ if (priorTsMs !== null && isHumanTextUser(line)) {
1151
+ const gap = tsMillis - priorTsMs;
1152
+ if (gap > 0) {
1153
+ idleMsInSlice += gap;
1154
+ }
1155
+ }
1156
+ priorTsMs = tsMillis;
1157
+ }
1158
+ if (line.type === "assistant" && line.message !== undefined) {
1159
+ // Anthropic msg_id dedup: skip the entire assistant block
1160
+ // (counters, tokens, cost, model, context, CONSUMER, EMITTER,
1161
+ // tool counting, step open) when this API response was already
1162
+ // counted via a prior line/slice. See MessageBody.id docstring
1163
+ // for why duplicates exist in real transcripts.
1164
+ const msgId = line.message.id;
1165
+ if (typeof msgId === "string" && msgId.length > 0) {
1166
+ if (seenAssistantMessageIds.has(msgId)) {
1167
+ // Duplicate API response re-emit — silently skip.
1168
+ continue;
1169
+ }
1170
+ seenAssistantMessageIds.add(msgId);
1171
+ newAssistantMessageIdsThisSlice.push(msgId);
1172
+ }
1173
+ assistantResponseCount += 1;
1174
+ assistantTurnIndex += 1;
1175
+ // Track timestamp for response-time gap to the next user message.
1176
+ if (tsMillis !== null) {
1177
+ lastAssistantMs = tsMillis;
1178
+ }
1179
+ // Per-turn: bump assistant_messages + last_activity_time.
1180
+ // Per-step: every assistant message starts a new step. If a
1181
+ // prior step was open in this turn, close it (push to
1182
+ // completed_steps) before opening the new one. Step start_time
1183
+ // is this assistant's timestamp (or lastTimestamp fallback for
1184
+ // determinism when ts is missing — same rule as openTurn).
1185
+ if (currentTurn !== null) {
1186
+ currentTurn.assistant_messages += 1;
1187
+ if (typeof tsString === "string" && tsString.length > 0) {
1188
+ currentTurn.last_activity_time = tsString;
1189
+ }
1190
+ if (currentTurn.current_step !== undefined) {
1191
+ currentTurn.completed_steps.push(currentTurn.current_step);
1192
+ }
1193
+ const stepStart = typeof tsString === "string" && tsString.length > 0
1194
+ ? tsString
1195
+ : lastTimestamp;
1196
+ currentTurn.current_step = openStep(currentTurn.next_step_index, stepStart);
1197
+ currentTurn.next_step_index += 1;
1198
+ }
1199
+ const model = line.message.model;
1200
+ const usage = line.message.usage;
1201
+ // Tokens — session-wide totals
1202
+ const msgInput = usage?.input_tokens ?? 0;
1203
+ const msgOutput = usage?.output_tokens ?? 0;
1204
+ const msgCacheCreation = usage?.cache_creation_input_tokens ?? 0;
1205
+ const msgCacheRead = usage?.cache_read_input_tokens ?? 0;
1206
+ // Cache-creation 5m / 1h split (Anthropic ephemeral tiers). When
1207
+ // present, used in cost calc to bill 1h tokens at the higher
1208
+ // rate; falls back to a single sum at 5m rate when absent.
1209
+ const msgCC5m = usage?.cache_creation?.ephemeral_5m_input_tokens ?? 0;
1210
+ const msgCC1h = usage?.cache_creation?.ephemeral_1h_input_tokens ?? 0;
1211
+ const hasCcSplit = (msgCC5m + msgCC1h) > 0;
1212
+ // Server tool use (web_search currently the only billable)
1213
+ const msgWebSearchReqs = usage?.server_tool_use?.web_search_requests ?? 0;
1214
+ // Fast mode tier (Opus 4.6 only — 6× regular rate)
1215
+ const msgSpeed = usage?.speed;
1216
+ inputTokens += msgInput;
1217
+ outputTokens += msgOutput;
1218
+ cacheCreationTokens += msgCacheCreation;
1219
+ cacheReadTokens += msgCacheRead;
1220
+ // Per-turn token totals — same additive rule as session-level.
1221
+ // Per-step: mirror onto the just-opened step.
1222
+ if (currentTurn !== null) {
1223
+ currentTurn.input_tokens += msgInput;
1224
+ currentTurn.output_tokens += msgOutput;
1225
+ currentTurn.cache_creation_tokens += msgCacheCreation;
1226
+ currentTurn.cache_read_tokens += msgCacheRead;
1227
+ if (currentTurn.current_step !== undefined) {
1228
+ currentTurn.current_step.input_tokens += msgInput;
1229
+ currentTurn.current_step.output_tokens += msgOutput;
1230
+ currentTurn.current_step.cache_creation_tokens += msgCacheCreation;
1231
+ currentTurn.current_step.cache_read_tokens += msgCacheRead;
1232
+ }
1233
+ }
1234
+ // Per-message cost — looked up by model id (family fallback +
1235
+ // fast-mode tier when speed=="fast" on Opus 4.6). Unknown
1236
+ // family contributes 0. computeMessageCostUsd handles the
1237
+ // 5m/1h cache split + web_search add-on.
1238
+ const pricing = typeof model === "string" && model.length > 0
1239
+ ? (0, pricing_1.lookupPricingForUsage)(model, msgSpeed)
1240
+ : null;
1241
+ const msgCost = (0, pricing_1.computeMessageCostUsd)({
1242
+ input_tokens: msgInput,
1243
+ output_tokens: msgOutput,
1244
+ cache_creation_tokens: msgCacheCreation,
1245
+ cache_read_tokens: msgCacheRead,
1246
+ ...(hasCcSplit ? {
1247
+ cache_creation_5m_tokens: msgCC5m,
1248
+ cache_creation_1h_tokens: msgCC1h,
1249
+ } : {}),
1250
+ web_search_requests: msgWebSearchReqs,
1251
+ }, pricing);
1252
+ costUsd += msgCost;
1253
+ // Per-API-request event emission. One record per non-duplicate
1254
+ // assistant line — covers both successful API responses and
1255
+ // terminal failure synthetic placeholders (`isApiErrorMessage`).
1256
+ // Body-only struct; emit wraps with the Event envelope.
1257
+ //
1258
+ // Field semantics verified against real transcripts:
1259
+ // - request_id: top-level `requestId` ONLY on success lines
1260
+ // (failure lines carry no join key → null).
1261
+ // - error: top-level `error` ONLY on failure lines.
1262
+ // - status_code: top-level `apiErrorStatus` rarely set even
1263
+ // on failures (~5% — only explicit HTTP errors like 429).
1264
+ // - speed: `usage.speed` typically "standard"; null on
1265
+ // synthetic-failure placeholder messages.
1266
+ // - duration: NOT in transcript at all → always null here
1267
+ // (transcript-derived). Java-side runtime emitters fill
1268
+ // this with real per-call latency.
1269
+ // - cost_usd: derived via pricing × tokens (= 0 on failures
1270
+ // since tokens are zero).
1271
+ const isApiErrorLine = line.isApiErrorMessage === true;
1272
+ const apiRequestId = typeof line.requestId === "string" && line.requestId.length > 0
1273
+ ? line.requestId
1274
+ : null;
1275
+ const apiErrorStr = isApiErrorLine && typeof line.error === "string"
1276
+ ? line.error
1277
+ : null;
1278
+ const apiStatusCode = isApiErrorLine && typeof line.apiErrorStatus === "number"
1279
+ ? line.apiErrorStatus
1280
+ : null;
1281
+ const apiSpeed = typeof msgSpeed === "string" && msgSpeed.length > 0
1282
+ ? msgSpeed
1283
+ : null;
1284
+ const apiTimestampMs = tsMillis ?? Date.now();
1285
+ // Event.id source: transcript line's top-level `uuid`. Verified
1286
+ // 100% presence on real transcripts (success + failure). Falls
1287
+ // back to a deterministic UUID-shaped id derived from
1288
+ // sha256(session_id|msg_id|timestamp) when uuid is missing
1289
+ // (defensive — never observed in production data; Cursor
1290
+ // transcripts have no uuid either way and don't reach this
1291
+ // path because they're skipped at emit-time).
1292
+ const apiEventId = typeof line.uuid === "string" && line.uuid.length > 0
1293
+ ? line.uuid
1294
+ : formatHexAsUuid((0, node_crypto_1.createHash)("sha256")
1295
+ .update(`api_request:${input.sessionId}:${msgId ?? "anon"}:${apiTimestampMs}`)
1296
+ .digest("hex")
1297
+ .slice(0, 32));
1298
+ apiRequestEvents.push({
1299
+ id: apiEventId,
1300
+ timestamp_ms: apiTimestampMs,
1301
+ request_id: apiRequestId,
1302
+ success: !isApiErrorLine,
1303
+ error: apiErrorStr,
1304
+ status_code: apiStatusCode,
1305
+ model: typeof model === "string" && model.length > 0 ? model : "<unknown>",
1306
+ speed: apiSpeed,
1307
+ input_tokens: msgInput,
1308
+ output_tokens: msgOutput,
1309
+ cache_read_tokens: msgCacheRead,
1310
+ cache_creation_tokens: msgCacheCreation,
1311
+ cost_usd: msgCost,
1312
+ duration: null,
1313
+ });
1314
+ // Advisor sub-call recursion. `usage.iterations` may carry
1315
+ // entries typed `"advisor_message"` whose tokens are billed
1316
+ // separately — they're NOT included in the parent usage.
1317
+ // Each advisor sub-call goes to unattributed_cost (no per-tool
1318
+ // attribution; advisor is a server-side recursive call,
1319
+ // priced at its own model's rate). Other iteration types
1320
+ // (notably `"message"` which mirrors the parent) are skipped.
1321
+ let advisorCostThisMsg = 0;
1322
+ const iterations = usage?.iterations;
1323
+ if (Array.isArray(iterations)) {
1324
+ for (const it of iterations) {
1325
+ if (it.type !== "advisor_message") {
1326
+ continue;
1327
+ }
1328
+ const advModel = it.model;
1329
+ const advPricing = typeof advModel === "string" && advModel.length > 0
1330
+ ? (0, pricing_1.lookupPricing)(advModel)
1331
+ : null;
1332
+ const advCC5m = it.cache_creation?.ephemeral_5m_input_tokens ?? 0;
1333
+ const advCC1h = it.cache_creation?.ephemeral_1h_input_tokens ?? 0;
1334
+ const advHasSplit = (advCC5m + advCC1h) > 0;
1335
+ const advCost = (0, pricing_1.computeMessageCostUsd)({
1336
+ input_tokens: it.input_tokens ?? 0,
1337
+ output_tokens: it.output_tokens ?? 0,
1338
+ cache_creation_tokens: it.cache_creation_input_tokens ?? 0,
1339
+ cache_read_tokens: it.cache_read_input_tokens ?? 0,
1340
+ ...(advHasSplit ? {
1341
+ cache_creation_5m_tokens: advCC5m,
1342
+ cache_creation_1h_tokens: advCC1h,
1343
+ } : {}),
1344
+ }, advPricing);
1345
+ advisorCostThisMsg += advCost;
1346
+ }
1347
+ }
1348
+ costUsd += advisorCostThisMsg;
1349
+ // Per-model usage breakdown — element-wise additive across deltas
1350
+ // in the merge step. Empty / missing model name skipped (no
1351
+ // attribution possible).
1352
+ if (typeof model === "string" && model.length > 0) {
1353
+ const slot = models[model] ?? {
1354
+ count: 0,
1355
+ input_tokens: 0,
1356
+ output_tokens: 0,
1357
+ cache_creation_tokens: 0,
1358
+ cache_read_tokens: 0,
1359
+ cost_usd: 0,
1360
+ };
1361
+ slot.count += 1;
1362
+ slot.input_tokens += msgInput;
1363
+ slot.output_tokens += msgOutput;
1364
+ slot.cache_creation_tokens += msgCacheCreation;
1365
+ slot.cache_read_tokens += msgCacheRead;
1366
+ slot.cost_usd += msgCost;
1367
+ models[model] = slot;
1368
+ // Per-turn parallel — same additive rule. Per-step too.
1369
+ if (currentTurn !== null) {
1370
+ const turnSlot = currentTurn.models[model] ?? {
1371
+ count: 0,
1372
+ input_tokens: 0,
1373
+ output_tokens: 0,
1374
+ cache_creation_tokens: 0,
1375
+ cache_read_tokens: 0,
1376
+ cost_usd: 0,
1377
+ };
1378
+ turnSlot.count += 1;
1379
+ turnSlot.input_tokens += msgInput;
1380
+ turnSlot.output_tokens += msgOutput;
1381
+ turnSlot.cache_creation_tokens += msgCacheCreation;
1382
+ turnSlot.cache_read_tokens += msgCacheRead;
1383
+ turnSlot.cost_usd += msgCost;
1384
+ currentTurn.models[model] = turnSlot;
1385
+ if (currentTurn.current_step !== undefined) {
1386
+ const stepSlot = currentTurn.current_step.models[model] ?? {
1387
+ count: 0,
1388
+ input_tokens: 0,
1389
+ output_tokens: 0,
1390
+ cache_creation_tokens: 0,
1391
+ cache_read_tokens: 0,
1392
+ cost_usd: 0,
1393
+ };
1394
+ stepSlot.count += 1;
1395
+ stepSlot.input_tokens += msgInput;
1396
+ stepSlot.output_tokens += msgOutput;
1397
+ stepSlot.cache_creation_tokens += msgCacheCreation;
1398
+ stepSlot.cache_read_tokens += msgCacheRead;
1399
+ stepSlot.cost_usd += msgCost;
1400
+ currentTurn.current_step.models[model] = stepSlot;
1401
+ }
1402
+ }
1403
+ }
1404
+ // Context-tokens distribution (turn-bucketed)
1405
+ if (usage !== undefined) {
1406
+ const ctx = msgInput + msgCacheCreation + msgCacheRead;
1407
+ if (ctx > 0) {
1408
+ hasAssistantWithUsage = true;
1409
+ const bucket = turnBucket(assistantTurnIndex);
1410
+ const slot = contextTokensBuckets[bucket]
1411
+ ?? { sum: 0, count: 0 };
1412
+ slot.sum += ctx;
1413
+ slot.count += 1;
1414
+ contextTokensBuckets[bucket] = slot;
1415
+ contextTokensLatest = ctx;
1416
+ if (ctx > contextTokensPeak) {
1417
+ contextTokensPeak = ctx;
1418
+ }
1419
+ // Per-turn samples — append in order; backend can
1420
+ // reconstruct session-level buckets by concatenating
1421
+ // every turn's samples in turn_index order.
1422
+ // Per-step: single scalar (one sample per step since a
1423
+ // step is bounded by exactly one assistant message).
1424
+ if (currentTurn !== null) {
1425
+ currentTurn.context_tokens_samples.push(ctx);
1426
+ currentTurn.context_tokens_latest = ctx;
1427
+ if (ctx > currentTurn.context_tokens_peak) {
1428
+ currentTurn.context_tokens_peak = ctx;
1429
+ }
1430
+ if (currentTurn.current_step !== undefined) {
1431
+ currentTurn.current_step.context_tokens = ctx;
1432
+ }
1433
+ }
1434
+ }
1435
+ }
1436
+ // ── Per-tool consumer: no-op ──────────────────────────────
1437
+ // Per-tool token / cost attribution does not exist. tool_result
1438
+ // bytes are recorded directly on `tools[T].output_size` at the
1439
+ // tool_result handler in the user-msg branch (no consumer
1440
+ // assistant N+1 attribution step needed). Anthropic-exact
1441
+ // tokens (msgInput, msgCacheCreation, msgCacheRead, msgOutput)
1442
+ // continue to flow into session/turn/step usage totals + per-
1443
+ // model cost. priorUserMsgTextBytes / hasCcSplit / msgCC5m /
1444
+ // msgCC1h / msgWebSearchReqs / advisorCostThisMsg references
1445
+ // exist for backward-compat with state shape but are unused
1446
+ // by per-tool attribution. msgWebSearchReqs cost still adds
1447
+ // to the session msgCost via computeMessageCostUsd above.
1448
+ void msgInput;
1449
+ void msgCacheCreation;
1450
+ void msgCacheRead;
1451
+ void hasCcSplit;
1452
+ void msgCC5m;
1453
+ void msgCC1h;
1454
+ void msgWebSearchReqs;
1455
+ void advisorCostThisMsg;
1456
+ void priorUserMsgTextBytes;
1457
+ resultedToolUseIds = [];
1458
+ // Walk content blocks for tool_use entries.
1459
+ // ── Per-tool emitter: byte counter ──────────────────────────
1460
+ // Each tool_use bumps the corresponding tool/mcp_server/skill/
1461
+ // sub_agent/bash_binary bucket's `input_size` by the JSON
1462
+ // byte length of `tool_use.input` (this is the model→tool
1463
+ // direction, hence "input"). Output bytes are recorded later
1464
+ // when the matching tool_result arrives — see the user-msg
1465
+ // branch's tool_result handler (it derives the tool's bucket
1466
+ // via the pending entry and adds to `output_size`). No
1467
+ // per-tool token / cost attribution here.
1468
+ const content = line.message.content;
1469
+ if (Array.isArray(content)) {
1470
+ const buckets = {
1471
+ tools, mcpServers, bashBinaries: bashBinariesRich, skills, subAgents,
1472
+ };
1473
+ for (const blk of content) {
1474
+ if (blk === null || typeof blk !== "object") {
1475
+ continue;
1476
+ }
1477
+ const block = blk;
1478
+ if (block.type !== "tool_use"
1479
+ || typeof block.name !== "string"
1480
+ || typeof block.id !== "string"
1481
+ || block.id.length === 0) {
1482
+ continue;
1483
+ }
1484
+ const inputJsonBytes = Buffer.byteLength(JSON.stringify(block.input ?? {}), "utf-8");
1485
+ // Sub-classification — same mutual-exclusion rules as before.
1486
+ let bashBinary;
1487
+ let skillName;
1488
+ let subAgentType;
1489
+ const inp = block.input;
1490
+ if (inp !== undefined) {
1491
+ if (block.name === "Bash") {
1492
+ const cmd = inp.command;
1493
+ if (typeof cmd === "string") {
1494
+ bashBinary = extractBashBinary(cmd);
1495
+ }
1496
+ }
1497
+ else if (block.name === "Skill") {
1498
+ const sk = inp.skill;
1499
+ if (typeof sk === "string" && sk.length > 0) {
1500
+ skillName = sk;
1501
+ }
1502
+ }
1503
+ else if (block.name === "Agent" || block.name === "Task") {
1504
+ const sa = inp.subagent_type;
1505
+ if (typeof sa === "string" && sa.length > 0) {
1506
+ subAgentType = sa;
1507
+ }
1508
+ }
1509
+ }
1510
+ const pending = {
1511
+ tool_name: block.name,
1512
+ ...(bashBinary !== undefined ? { bash_binary: bashBinary } : {}),
1513
+ ...(skillName !== undefined ? { skill_name: skillName } : {}),
1514
+ ...(subAgentType !== undefined ? { sub_agent_type: subAgentType } : {}),
1515
+ ...(currentTurn !== null ? { originating_turn_index: currentTurn.turn_index } : {}),
1516
+ };
1517
+ applyToBucketsAndTurn(pending, (slot) => {
1518
+ slot.input_size += inputJsonBytes;
1519
+ slot.approximated_input_tokens = slot.input_size / pricing_1.BYTES_PER_TOKEN;
1520
+ }, buckets, currentTurn);
1521
+ // Pending entry survives until the tool_result lands;
1522
+ // the user-msg branch's tool_result handler uses it
1523
+ // to look up which tool/mcp_server/skill/etc. bucket
1524
+ // the tool_result.content bytes belong to.
1525
+ pendingToolUses[block.id] = pending;
1526
+ }
1527
+ }
1528
+ void msgOutput; // output side is not per-tool attributed
1529
+ // Per-message tool-bucket flag accumulator for the classifier
1530
+ // retry state machine. Computed locally within this assistant
1531
+ // message; merged into currentTurn at the end.
1532
+ const msgBuckets = (0, classifier_1.emptyToolBucketFlags)();
1533
+ if (Array.isArray(content)) {
1534
+ for (const blk of content) {
1535
+ if (blk === null || typeof blk !== "object") {
1536
+ continue;
1537
+ }
1538
+ const block = blk;
1539
+ if (block.type === "tool_use" && typeof block.name === "string") {
1540
+ const toolName = block.name;
1541
+ // Per-tool breakdown count — also update mcp_servers,
1542
+ // bash_binaries, skills, sub_agents in the same step.
1543
+ // Build a lightweight pending-shaped record from this
1544
+ // tool_use for the multi-bucket helper.
1545
+ let bashBinaryHere;
1546
+ let skillNameHere;
1547
+ let subAgentTypeHere;
1548
+ const inpHere = block.input;
1549
+ if (inpHere !== undefined) {
1550
+ if (toolName === "Bash") {
1551
+ const cmdRaw = inpHere.command;
1552
+ if (typeof cmdRaw === "string") {
1553
+ bashBinaryHere = extractBashBinary(cmdRaw);
1554
+ }
1555
+ }
1556
+ else if (toolName === "Skill") {
1557
+ const skRaw = inpHere.skill;
1558
+ if (typeof skRaw === "string" && skRaw.length > 0) {
1559
+ skillNameHere = skRaw;
1560
+ }
1561
+ }
1562
+ else if (toolName === "Agent" || toolName === "Task") {
1563
+ const saRaw = inpHere.subagent_type;
1564
+ if (typeof saRaw === "string" && saRaw.length > 0) {
1565
+ subAgentTypeHere = saRaw;
1566
+ }
1567
+ }
1568
+ }
1569
+ const pendingShape = {
1570
+ tool_name: toolName,
1571
+ ...(bashBinaryHere !== undefined ? { bash_binary: bashBinaryHere } : {}),
1572
+ ...(skillNameHere !== undefined ? { skill_name: skillNameHere } : {}),
1573
+ ...(subAgentTypeHere !== undefined ? { sub_agent_type: subAgentTypeHere } : {}),
1574
+ ...(currentTurn !== null ? { originating_turn_index: currentTurn.turn_index } : {}),
1575
+ };
1576
+ applyToBucketsAndTurn(pendingShape, (slot) => { slot.count += 1; }, { tools, mcpServers, bashBinaries: bashBinariesRich, skills, subAgents }, currentTurn);
1577
+ // Per-turn: count this tool_use. Mirror onto step.
1578
+ if (currentTurn !== null) {
1579
+ currentTurn.tool_calls += 1;
1580
+ if (currentTurn.current_step !== undefined) {
1581
+ currentTurn.current_step.tool_calls += 1;
1582
+ }
1583
+ }
1584
+ // Classifier — update tool-bucket flags for this message
1585
+ // and the running turn state.
1586
+ (0, classifier_1.applyToolBucketFlags)(msgBuckets, toolName);
1587
+ if (currentTurn !== null) {
1588
+ (0, classifier_1.applyToolBucketFlags)({
1589
+ edit: currentTurn.has_edit, read: currentTurn.has_read,
1590
+ bash: currentTurn.has_bash, task: currentTurn.has_task,
1591
+ search: currentTurn.has_search, mcp: currentTurn.has_mcp,
1592
+ skill: currentTurn.has_skill, plan: currentTurn.has_plan,
1593
+ }, toolName);
1594
+ // applyToolBucketFlags ORs into a NEW object above —
1595
+ // we need to re-OR onto currentTurn. Just call again
1596
+ // directly on a flags-shaped view of currentTurn:
1597
+ currentTurn.has_edit = currentTurn.has_edit || msgBuckets.edit;
1598
+ currentTurn.has_read = currentTurn.has_read || msgBuckets.read;
1599
+ currentTurn.has_bash = currentTurn.has_bash || msgBuckets.bash;
1600
+ currentTurn.has_task = currentTurn.has_task || msgBuckets.task;
1601
+ currentTurn.has_search = currentTurn.has_search || msgBuckets.search;
1602
+ currentTurn.has_mcp = currentTurn.has_mcp || msgBuckets.mcp;
1603
+ currentTurn.has_skill = currentTurn.has_skill || msgBuckets.skill;
1604
+ currentTurn.has_plan = currentTurn.has_plan || msgBuckets.plan;
1605
+ }
1606
+ if (toolName === "Task" || toolName === "Agent") {
1607
+ usesSubAgent = true;
1608
+ if (currentTurn !== null) {
1609
+ currentTurn.uses_sub_agent = true;
1610
+ if (currentTurn.current_step !== undefined) {
1611
+ currentTurn.current_step.uses_sub_agent = true;
1612
+ }
1613
+ }
1614
+ }
1615
+ if (toolName === "Skill") {
1616
+ usesSkill = true;
1617
+ if (currentTurn !== null) {
1618
+ currentTurn.uses_skill = true;
1619
+ if (currentTurn.current_step !== undefined) {
1620
+ currentTurn.current_step.uses_skill = true;
1621
+ }
1622
+ }
1623
+ }
1624
+ if (toolName.startsWith("mcp__")) {
1625
+ usesMcp = true;
1626
+ if (currentTurn !== null) {
1627
+ currentTurn.uses_mcp = true;
1628
+ if (currentTurn.current_step !== undefined) {
1629
+ currentTurn.current_step.uses_mcp = true;
1630
+ }
1631
+ }
1632
+ }
1633
+ if (toolName === "WebSearch") {
1634
+ usesWebSearch = true;
1635
+ if (currentTurn !== null) {
1636
+ currentTurn.uses_web_search = true;
1637
+ if (currentTurn.current_step !== undefined) {
1638
+ currentTurn.current_step.uses_web_search = true;
1639
+ }
1640
+ }
1641
+ }
1642
+ if (toolName === "WebFetch") {
1643
+ usesWebFetch = true;
1644
+ if (currentTurn !== null) {
1645
+ currentTurn.uses_web_fetch = true;
1646
+ if (currentTurn.current_step !== undefined) {
1647
+ currentTurn.current_step.uses_web_fetch = true;
1648
+ }
1649
+ }
1650
+ }
1651
+ const inp = block.input;
1652
+ if (inp !== undefined) {
1653
+ // Languages + files_modified — driven by file_path
1654
+ const filePath = inp.file_path;
1655
+ if (typeof filePath === "string" && filePath.length > 0) {
1656
+ if (toolName === "Edit" || toolName === "Write") {
1657
+ distinctFilePaths.add(filePath);
1658
+ filePathChangeCounts[filePath] =
1659
+ (filePathChangeCounts[filePath] ?? 0) + 1;
1660
+ if (currentTurn !== null) {
1661
+ currentTurn.file_change_counts[filePath] =
1662
+ (currentTurn.file_change_counts[filePath] ?? 0) + 1;
1663
+ if (currentTurn.current_step !== undefined) {
1664
+ currentTurn.current_step.file_change_counts[filePath] =
1665
+ (currentTurn.current_step.file_change_counts[filePath] ?? 0) + 1;
1666
+ }
1667
+ }
1668
+ }
1669
+ const lang = getLanguageFromPath(filePath);
1670
+ if (lang !== null) {
1671
+ languages[lang] = (languages[lang] ?? 0) + 1;
1672
+ if (currentTurn !== null) {
1673
+ currentTurn.languages[lang] = (currentTurn.languages[lang] ?? 0) + 1;
1674
+ if (currentTurn.current_step !== undefined) {
1675
+ currentTurn.current_step.languages[lang] =
1676
+ (currentTurn.current_step.languages[lang] ?? 0) + 1;
1677
+ }
1678
+ }
1679
+ }
1680
+ }
1681
+ // Bash — count git_commits / git_pushes via substring,
1682
+ // but emit only the binary + subcommand labels.
1683
+ if (toolName === "Bash") {
1684
+ const cmd = inp.command;
1685
+ if (typeof cmd === "string") {
1686
+ // bash_binaries count is incremented above via
1687
+ // applyToBuckets — no separate update needed here.
1688
+ const sub = extractBashSubcommand(cmd);
1689
+ if (sub !== undefined) {
1690
+ bashSubcommands[sub] = (bashSubcommands[sub] ?? 0) + 1;
1691
+ if (currentTurn !== null) {
1692
+ currentTurn.bash_subcommands[sub] =
1693
+ (currentTurn.bash_subcommands[sub] ?? 0) + 1;
1694
+ if (currentTurn.current_step !== undefined) {
1695
+ currentTurn.current_step.bash_subcommands[sub] =
1696
+ (currentTurn.current_step.bash_subcommands[sub] ?? 0) + 1;
1697
+ }
1698
+ }
1699
+ }
1700
+ // Classifier — scan command body for
1701
+ // test/build/install/git keywords. Match
1702
+ // outcomes are booleans, not text.
1703
+ if (currentTurn !== null) {
1704
+ const bashFlags = (0, classifier_1.emptyBashCmdFlags)();
1705
+ (0, classifier_1.applyBashCmdFlags)(bashFlags, cmd);
1706
+ currentTurn.bash_test = currentTurn.bash_test || bashFlags.test;
1707
+ currentTurn.bash_build = currentTurn.bash_build || bashFlags.build;
1708
+ currentTurn.bash_install = currentTurn.bash_install || bashFlags.install;
1709
+ currentTurn.bash_git = currentTurn.bash_git || bashFlags.git;
1710
+ }
1711
+ }
1712
+ }
1713
+ // Edit — line-diff via the diff package
1714
+ if (toolName === "Edit") {
1715
+ const oldS = inp.old_string;
1716
+ const newS = inp.new_string;
1717
+ if (typeof oldS === "string" && typeof newS === "string") {
1718
+ try {
1719
+ for (const chg of (0, diff_1.diffLines)(oldS, newS)) {
1720
+ if (chg.added) {
1721
+ linesAdded += chg.count ?? 0;
1722
+ if (currentTurn !== null) {
1723
+ currentTurn.lines_added += chg.count ?? 0;
1724
+ if (currentTurn.current_step !== undefined) {
1725
+ currentTurn.current_step.lines_added += chg.count ?? 0;
1726
+ }
1727
+ }
1728
+ }
1729
+ else if (chg.removed) {
1730
+ linesRemoved += chg.count ?? 0;
1731
+ if (currentTurn !== null) {
1732
+ currentTurn.lines_removed += chg.count ?? 0;
1733
+ if (currentTurn.current_step !== undefined) {
1734
+ currentTurn.current_step.lines_removed += chg.count ?? 0;
1735
+ }
1736
+ }
1737
+ }
1738
+ }
1739
+ }
1740
+ catch (e) {
1741
+ logger_1.logger.debug(`projection: diffLines failed: ${e instanceof Error ? e.message : e}`);
1742
+ }
1743
+ }
1744
+ }
1745
+ // Write — count newlines in content (all "added").
1746
+ if (toolName === "Write") {
1747
+ const wc = inp.content;
1748
+ if (typeof wc === "string" && wc.length > 0) {
1749
+ let nl = 1; // last line counts even without trailing \n
1750
+ for (let i = 0; i < wc.length; i++) {
1751
+ if (wc.charCodeAt(i) === 0x0a) {
1752
+ nl += 1;
1753
+ }
1754
+ }
1755
+ linesAdded += nl;
1756
+ if (currentTurn !== null) {
1757
+ currentTurn.lines_added += nl;
1758
+ if (currentTurn.current_step !== undefined) {
1759
+ currentTurn.current_step.lines_added += nl;
1760
+ }
1761
+ }
1762
+ }
1763
+ }
1764
+ }
1765
+ }
1766
+ }
1767
+ }
1768
+ // End-of-message classifier update: retry detection +
1769
+ // per-turn cost accumulation. Only if we have an open turn.
1770
+ if (currentTurn !== null) {
1771
+ if (msgBuckets.edit) {
1772
+ if (currentTurn.saw_bash_after_edit) {
1773
+ currentTurn.retries += 1;
1774
+ }
1775
+ currentTurn.saw_edit_pending_bash = true;
1776
+ currentTurn.saw_bash_after_edit = false;
1777
+ }
1778
+ if (msgBuckets.bash && currentTurn.saw_edit_pending_bash) {
1779
+ currentTurn.saw_bash_after_edit = true;
1780
+ }
1781
+ currentTurn.cost_usd += msgCost;
1782
+ if (currentTurn.current_step !== undefined) {
1783
+ currentTurn.current_step.cost_usd += msgCost;
1784
+ }
1785
+ }
1786
+ }
1787
+ if (line.type === "user" && line.message !== undefined) {
1788
+ const isHuman = isHumanTextUser(line);
1789
+ const isInterrupted = isInterruptedUser(line);
1790
+ // Attribute `was_interrupted` to the turn that was ACTIVE when
1791
+ // the interrupt arrived — i.e., the soon-to-close prior turn,
1792
+ // not the one this same user msg may about to open. Real
1793
+ // Claude interrupts arrive as text user msgs containing the
1794
+ // INTERRUPT_MARKER, so they trip both `isHumanTextUser` AND
1795
+ // `isInterruptedUser`. Without setting the flag here, the
1796
+ // close/open cycle below would close the wrong turn cleanly
1797
+ // and tag the new (restart) turn instead.
1798
+ if (isInterrupted && currentTurn !== null) {
1799
+ currentTurn.was_interrupted = true;
1800
+ if (currentTurn.current_step !== undefined) {
1801
+ currentTurn.current_step.was_interrupted = true;
1802
+ }
1803
+ }
1804
+ if (isHuman) {
1805
+ // isMeta=true + currentTurn open → CONTINUATION semantic.
1806
+ // Host-injected user msgs (slash command bodies, hook
1807
+ // feedback) that arrive while a turn is still in-flight
1808
+ // are part of that turn's logical work — no new boundary.
1809
+ // Just extend the open turn's activity window so timing
1810
+ // bookkeeping stays accurate; do NOT increment user_turns
1811
+ // (it's not a distinct user action).
1812
+ //
1813
+ // isMeta=true + currentTurn null → opens a NEW turn. This
1814
+ // is the hook-feedback-after-Stop path: the prior turn was
1815
+ // force-closed at Stop, so retry assistant work needs a
1816
+ // fresh turn to attribute to. Treated as a normal turn
1817
+ // open below.
1818
+ const isContinuation = line.isMeta === true && currentTurn !== null;
1819
+ if (isContinuation) {
1820
+ if (typeof tsString === "string" && tsString.length > 0 && currentTurn !== null) {
1821
+ currentTurn.last_activity_time = tsString;
1822
+ if (currentTurn.current_step !== undefined) {
1823
+ currentTurn.current_step.last_activity_time = tsString;
1824
+ }
1825
+ }
1826
+ }
1827
+ else {
1828
+ // Close the prior turn (if any) — real user msg (or
1829
+ // isMeta with no current turn) is a turn boundary.
1830
+ if (currentTurn !== null) {
1831
+ const closeEndTime = typeof tsString === "string" && tsString.length > 0
1832
+ ? tsString
1833
+ : currentTurn.last_activity_time;
1834
+ const r = closeTurn(currentTurn, {
1835
+ endTime: closeEndTime,
1836
+ endReason: "next_user_msg",
1837
+ sessionId, projectName, transcriptSource,
1838
+ });
1839
+ applyBreakdownDelta(categoryBreakdown, r);
1840
+ turnsWithRetryInSlice += r.turns_with_retry_inc;
1841
+ oneShotTurnsInSlice += r.one_shot_inc;
1842
+ completedTurns.push(r.turn_event);
1843
+ for (const stepEvent of r.step_events) {
1844
+ completedSteps.push(stepEvent);
1845
+ }
1846
+ }
1847
+ // Determinism: when the user msg has no timestamp (rare in
1848
+ // real Claude transcripts but allowed by the parser), fall
1849
+ // back to the most recent ts already seen — never to
1850
+ // wall-clock now(), which would make projection output
1851
+ // depend on system time.
1852
+ const openStartTime = typeof tsString === "string" && tsString.length > 0
1853
+ ? tsString
1854
+ : lastTimestamp;
1855
+ // Triggered_by classification: isMeta lines that
1856
+ // reach here (continuation impossible because no
1857
+ // open turn) are host-injected — usually hook
1858
+ // feedback after a Stop force-close. Tag them so
1859
+ // the backend can group "real prompt + N retries"
1860
+ // as one logical user invocation.
1861
+ const triggeredBy = line.isMeta === true
1862
+ ? "host_inject"
1863
+ : "user_msg";
1864
+ const userMsgText = extractUserMsgText(line);
1865
+ currentTurn = openTurn(userMsgText, nextTurnIndex, openStartTime, triggeredBy);
1866
+ nextTurnIndex += 1;
1867
+ userTurns += 1;
1868
+ // user_messages aggregate — count + bytes of text content.
1869
+ // host_inject (isMeta=true) DOES count toward bytes/count
1870
+ // here too, mirroring user_turns semantic. Backend can
1871
+ // separate via `triggered_by` on the turn record.
1872
+ userMessagesCount += 1;
1873
+ userMessagesSize += Buffer.byteLength(userMsgText, "utf-8");
1874
+ if (tsMillis !== null) {
1875
+ const d = new Date(tsMillis);
1876
+ const hour = d.getHours();
1877
+ const hourKey = String(hour);
1878
+ messagesByHour[hourKey] = (messagesByHour[hourKey] ?? 0) + 1;
1879
+ const dateKey = formatDateKey(d);
1880
+ messagesByDate[dateKey] = (messagesByDate[dateKey] ?? 0) + 1;
1881
+ const weekdayKey = formatWeekdayKey(d);
1882
+ messagesByWeekday[weekdayKey] = (messagesByWeekday[weekdayKey] ?? 0) + 1;
1883
+ if (lastAssistantMs !== null) {
1884
+ const gapMs = tsMillis - lastAssistantMs;
1885
+ const gapSec = gapMs / 1000;
1886
+ if (gapSec > RESPONSE_TIME_MIN_SEC && gapSec < RESPONSE_TIME_MAX_SEC) {
1887
+ const rtBucket = responseTimeBucket(gapMs);
1888
+ if (rtBucket !== null) {
1889
+ responseTimeBuckets[rtBucket] = (responseTimeBuckets[rtBucket] ?? 0) + 1;
1890
+ }
1891
+ }
1892
+ }
1893
+ }
1894
+ }
1895
+ }
1896
+ if (isInterrupted) {
1897
+ userInterruptions += 1;
1898
+ // (was_interrupted on the per-turn aggregator is already
1899
+ // set above, BEFORE the close/open cycle, so it lands on
1900
+ // the interrupted turn rather than the restart turn.)
1901
+ }
1902
+ // Per-turn last_activity_time — every user msg with a ts updates it.
1903
+ // Per-step: same; the user msg with tool_results extends the
1904
+ // current step's activity window (the in-flight assistant's
1905
+ // results are landing right now).
1906
+ if (currentTurn !== null && typeof tsString === "string" && tsString.length > 0) {
1907
+ currentTurn.last_activity_time = tsString;
1908
+ if (currentTurn.current_step !== undefined) {
1909
+ currentTurn.current_step.last_activity_time = tsString;
1910
+ }
1911
+ }
1912
+ // Walk content for tool_result error categorization + per-tool input
1913
+ // attribution. Each tool_result transitions its pending entry to
1914
+ // phase="resulted"; the consumer assistant message N+1 (next iter)
1915
+ // closes input attribution.
1916
+ const content = line.message.content;
1917
+ const toolResultIdsInThisMsg = [];
1918
+ // Sum of human-typed text bytes in this user msg's content blocks.
1919
+ // String-form content counts whole; structured content counts only
1920
+ // text blocks. tool_result content is excluded (those are tracked
1921
+ // separately as result_bytes per pending tool_use). Used to widen
1922
+ // the cache_creation denominator at CONSUMER PHASE — see comment
1923
+ // there. Privacy: only the byte count is retained, not the text.
1924
+ let userMsgTextBytesInThisMsg = 0;
1925
+ if (typeof content === "string") {
1926
+ userMsgTextBytesInThisMsg = Buffer.byteLength(content, "utf-8");
1927
+ }
1928
+ if (Array.isArray(content)) {
1929
+ for (const blk of content) {
1930
+ if (blk === null || typeof blk !== "object") {
1931
+ continue;
1932
+ }
1933
+ const block = blk;
1934
+ if (block.type === "text" && typeof block.text === "string") {
1935
+ userMsgTextBytesInThisMsg += Buffer.byteLength(block.text, "utf-8");
1936
+ }
1937
+ if (block.type === "tool_result") {
1938
+ const tuid = block.tool_use_id;
1939
+ // Resolve the originating tool_use (in pending) so we can
1940
+ // attribute per-tool errors. Pending entry is keyed by
1941
+ // tool_use_id; phase may be "emitted" (just arrived) or
1942
+ // "resulted" (already saw an earlier result line — rare).
1943
+ const pendingEntry = typeof tuid === "string"
1944
+ ? pendingToolUses[tuid]
1945
+ : undefined;
1946
+ if (block.is_error === true) {
1947
+ toolErrorsTotal += 1;
1948
+ const cat = classifyToolError(block.content);
1949
+ toolErrorCategories[cat] = (toolErrorCategories[cat] ?? 0) + 1;
1950
+ // Cross-turn attribution gate: if the pending entry
1951
+ // was emitted in a DIFFERENT turn (Stop force-closed
1952
+ // the originator before the result landed), skip
1953
+ // per-turn attribution — that turn was already
1954
+ // emitted and its counters are frozen on the wire.
1955
+ // Session-level totals always increment; backend
1956
+ // sees Σ turn.errors ≤ session.errors in this case
1957
+ // (slight under-count rather than wrong attribution).
1958
+ const sameOriginatingTurn = currentTurn !== null
1959
+ && (pendingEntry === undefined
1960
+ || pendingEntry.originating_turn_index === undefined
1961
+ || pendingEntry.originating_turn_index === currentTurn.turn_index);
1962
+ if (currentTurn !== null && sameOriginatingTurn) {
1963
+ currentTurn.tool_errors += 1;
1964
+ currentTurn.tool_error_categories[cat] =
1965
+ (currentTurn.tool_error_categories[cat] ?? 0) + 1;
1966
+ if (currentTurn.current_step !== undefined) {
1967
+ currentTurn.current_step.tool_errors += 1;
1968
+ currentTurn.current_step.tool_error_categories[cat] =
1969
+ (currentTurn.current_step.tool_error_categories[cat] ?? 0) + 1;
1970
+ }
1971
+ }
1972
+ // Per-tool error attribution — needs the originating
1973
+ // tool name from pending. If pending is missing
1974
+ // (orphan tool_result), skip — we already counted in
1975
+ // the totals + categories above. Same cross-turn
1976
+ // gate applies to per-turn slot updates.
1977
+ if (pendingEntry !== undefined) {
1978
+ applyToBucketsAndTurn(pendingEntry, (slot) => { slot.errors += 1; }, { tools, mcpServers, bashBinaries: bashBinariesRich, skills, subAgents }, sameOriginatingTurn ? currentTurn : null);
1979
+ }
1980
+ }
1981
+ // Write output_size directly here. The pending
1982
+ // entry was created at the per-tool emitter
1983
+ // (assistant tool_use). We look up the tool's
1984
+ // bucket via the pending entry and bump its
1985
+ // output_size by the tool_result content bytes.
1986
+ // After that the pending entry can be retired —
1987
+ // no per-tool token / cost attribution downstream.
1988
+ if (typeof tuid === "string" && tuid.length > 0 && pendingEntry !== undefined) {
1989
+ const resultBytes = Buffer.byteLength(JSON.stringify(block.content ?? null), "utf-8");
1990
+ const sameOriginatingTurnForResult = currentTurn !== null
1991
+ && (pendingEntry.originating_turn_index === undefined
1992
+ || pendingEntry.originating_turn_index === currentTurn.turn_index);
1993
+ applyToBucketsAndTurn(pendingEntry, (slot) => {
1994
+ slot.output_size += resultBytes;
1995
+ slot.approximated_output_tokens = slot.output_size / pricing_1.BYTES_PER_TOKEN;
1996
+ }, { tools, mcpServers, bashBinaries: bashBinariesRich, skills, subAgents }, sameOriginatingTurnForResult ? currentTurn : null);
1997
+ toolResultIdsInThisMsg.push(tuid);
1998
+ delete pendingToolUses[tuid];
1999
+ }
2000
+ // else: orphan tool_result (no matching emitter in this
2001
+ // slice or prior pending) — ignore.
2002
+ }
2003
+ }
2004
+ }
2005
+ // Pass forward only if THIS user msg actually had tool_results — a
2006
+ // user msg with only human text resets the pending consumer queue
2007
+ // (those tool_results, if any, are already from a prior turn).
2008
+ // priorUserMsgTextBytes tracks the human-typed text bytes that
2009
+ // contributed to N+1's cache_creation alongside the tool_result(s).
2010
+ if (toolResultIdsInThisMsg.length > 0) {
2011
+ resultedToolUseIds = toolResultIdsInThisMsg;
2012
+ priorUserMsgTextBytes = userMsgTextBytesInThisMsg;
2013
+ }
2014
+ else {
2015
+ resultedToolUseIds = [];
2016
+ priorUserMsgTextBytes = 0;
2017
+ }
2018
+ }
2019
+ }
2020
+ catch (e) {
2021
+ const ctx = `${line.type ?? "unknown"}:line_index=${lineIndex}`;
2022
+ const errTs = new Date().toISOString();
2023
+ (0, errors_1.recordProcessError)(processErrors, e, errTs, ctx);
2024
+ if (currentTurn !== null) {
2025
+ (0, errors_1.recordProcessError)(currentTurn.process_errors, e, errTs, ctx);
2026
+ if (currentTurn.current_step !== undefined) {
2027
+ (0, errors_1.recordProcessError)(currentTurn.current_step.process_errors, e, errTs, ctx);
2028
+ }
2029
+ }
2030
+ logger_1.logger.debug(`projection: line ${lineIndex} (${line.type ?? "unknown"}) processing failed; skipped: ${e instanceof Error ? e.message : e}`);
2031
+ }
2032
+ }
2033
+ // Build hot_files preview from the delta-local map. The merge step
2034
+ // recomputes the canonical top-K from the full internal map; this
2035
+ // preview is mostly for tests that look at a delta in isolation.
2036
+ const hotFilesEntries = Object.entries(filePathChangeCounts);
2037
+ hotFilesEntries.sort((a, b) => {
2038
+ if (b[1] !== a[1]) {
2039
+ return b[1] - a[1];
2040
+ }
2041
+ return a[0].localeCompare(b[0]);
2042
+ });
2043
+ const hotFiles = hotFilesEntries
2044
+ .slice(0, DELTA_HOT_FILES_LIMIT)
2045
+ .map(([path, count]) => ({
2046
+ path,
2047
+ change_count: count,
2048
+ }));
2049
+ // Recompute duration_minutes from the delta's bounds. Merge will
2050
+ // override using the accumulated bounds, but emitting a sane value
2051
+ // for delta-only consumers is harmless.
2052
+ let durationMinutes = 0;
2053
+ const firstMs = tsMs(firstTimestamp);
2054
+ const lastMs = tsMs(lastTimestamp);
2055
+ if (firstMs !== null && lastMs !== null && lastMs > firstMs) {
2056
+ durationMinutes = Math.round((lastMs - firstMs) / 60000);
2057
+ }
2058
+ // Delta-local active/idle minutes — useful for tests inspecting a single
2059
+ // delta. Cross-slice canonical totals are computed in merge from
2060
+ // `internal.idle_ms_total`. Same rounding semantics.
2061
+ const idleMinutesDelta = Math.round(idleMsInSlice / 60000);
2062
+ const activeMinutesDelta = Math.max(0, durationMinutes - idleMinutesDelta);
2063
+ // Hour-of-day for the delta's bounds. Merge "Static" / "Bounds-latest"
2064
+ // rules in merge.ts will keep accumulated.start_hour from the first
2065
+ // projection and update accumulated.last_activity_hour from the
2066
+ // accumulated.last_activity_time string.
2067
+ const startHourDelta = firstMs !== null ? new Date(firstMs).getHours() : 0;
2068
+ const lastActivityHourDelta = lastMs !== null ? new Date(lastMs).getHours() : 0;
2069
+ // context_tokens.buckets carries `avg = sum/count`. Computed at delta
2070
+ // build; merge recomputes on cross-slice merge to avoid drift.
2071
+ const contextTokensBucketsWithAvg = {};
2072
+ for (const [k, v] of Object.entries(contextTokensBuckets)) {
2073
+ contextTokensBucketsWithAvg[k] = {
2074
+ sum: v.sum,
2075
+ count: v.count,
2076
+ avg: v.count > 0 ? v.sum / v.count : 0,
2077
+ };
2078
+ }
2079
+ const delta = {
2080
+ // Identity / provenance — Static rule: merge keeps accumulated's
2081
+ session_id: sessionId,
2082
+ project_name: projectName,
2083
+ schema_version: types_1.SCHEMA_VERSION,
2084
+ transcript_source: transcriptSource,
2085
+ // Time bounds (flat ISO strings)
2086
+ start_time: firstTimestamp,
2087
+ last_activity_time: lastTimestamp,
2088
+ // Logical groups
2089
+ time: {
2090
+ duration_minutes: durationMinutes,
2091
+ active_minutes: activeMinutesDelta,
2092
+ idle_minutes: idleMinutesDelta,
2093
+ start_hour: startHourDelta,
2094
+ last_activity_hour: lastActivityHourDelta,
2095
+ },
2096
+ turns: {
2097
+ user_count: userTurns,
2098
+ assistant_count: assistantResponseCount,
2099
+ with_retry: turnsWithRetryInSlice,
2100
+ one_shot: oneShotTurnsInSlice,
2101
+ },
2102
+ classification: {
2103
+ // session_type is a delta-local approximation; merge.ts recomputes
2104
+ // it from the merged accumulated values once final bounds are known.
2105
+ category_breakdown: categoryBreakdown,
2106
+ session_type: "general",
2107
+ },
2108
+ usage: {
2109
+ input_tokens: inputTokens,
2110
+ output_tokens: outputTokens,
2111
+ cache_creation_tokens: cacheCreationTokens,
2112
+ cache_read_tokens: cacheReadTokens,
2113
+ cost_usd: costUsd,
2114
+ },
2115
+ // Per-model + user_messages
2116
+ models,
2117
+ user_messages: {
2118
+ count: userMessagesCount,
2119
+ size: userMessagesSize,
2120
+ approximated_tokens: userMessagesSize / pricing_1.BYTES_PER_TOKEN,
2121
+ },
2122
+ // Per-tool breakdown maps (top-level)
2123
+ tools,
2124
+ mcp_servers: mcpServers,
2125
+ skills,
2126
+ sub_agents: subAgents,
2127
+ bash_binaries: bashBinariesRich,
2128
+ // Group rollups
2129
+ tool_meta: {
2130
+ ...(Object.keys(bashSubcommands).length > 0 ? { bash_subcommands: bashSubcommands } : {}),
2131
+ uses_sub_agent: usesSubAgent,
2132
+ uses_skill: usesSkill,
2133
+ uses_mcp: usesMcp,
2134
+ uses_web_search: usesWebSearch,
2135
+ uses_web_fetch: usesWebFetch,
2136
+ },
2137
+ code_changes: {
2138
+ files_modified: distinctFilePaths.size,
2139
+ lines_added: linesAdded,
2140
+ lines_removed: linesRemoved,
2141
+ hot_files: hotFiles,
2142
+ languages,
2143
+ },
2144
+ errors: {
2145
+ tool_errors_total: toolErrorsTotal,
2146
+ tool_error_categories: toolErrorCategories,
2147
+ user_interruptions: userInterruptions,
2148
+ },
2149
+ user_activity: {
2150
+ response_time_buckets: responseTimeBuckets,
2151
+ messages_by_hour: messagesByHour,
2152
+ messages_by_date: messagesByDate,
2153
+ messages_by_weekday: messagesByWeekday,
2154
+ },
2155
+ context_tokens: {
2156
+ latest: contextTokensLatest,
2157
+ peak: contextTokensPeak,
2158
+ buckets: contextTokensBucketsWithAvg,
2159
+ },
2160
+ process_errors: {
2161
+ has: Object.keys(processErrors).length > 0,
2162
+ items: processErrors,
2163
+ },
2164
+ // Delta-local signals for merge (stripped before emit; not in AccumulatedAnalytics)
2165
+ has_assistant_with_usage: hasAssistantWithUsage,
2166
+ closing_pending_tool_uses: pendingToolUses,
2167
+ closing_current_turn: currentTurn,
2168
+ completed_turns: completedTurns,
2169
+ completed_steps: completedSteps,
2170
+ api_request_events: apiRequestEvents,
2171
+ };
2172
+ return delta;
2173
+ }
2174
+ /**
2175
+ * Re-walk the same lines to extract the per-delta internal aggregation
2176
+ * fields (path→count map + distinct set + last assistant timestamp) the
2177
+ * merge step needs to update `internal.*` on the state file. Kept separate
2178
+ * so the wire-level `DeltaAnalytics` (sent through merge) doesn't carry
2179
+ * these maps.
2180
+ */
2181
+ function projectDeltaInternal(input) {
2182
+ const filePathChangeCounts = {};
2183
+ const distinct = new Set();
2184
+ let lastAssistantTsMs;
2185
+ // Idle attribution mirror — same algorithm as projectDelta, computed
2186
+ // here too so the merge step has access to the ms-precision idle gap
2187
+ // without piggy-backing on the wire-level DeltaAnalytics.
2188
+ let priorTsMs = input.priorLastActivityTsMs ?? null;
2189
+ let idleMsDelta = 0;
2190
+ // Mirror of projectDelta's nextTurnIndex — count human-user msgs seen
2191
+ // here so we can report the post-walk counter back to merge.
2192
+ //
2193
+ // Continuation semantic: isMeta=true user msgs that arrive while a turn
2194
+ // is open are continuations (not new turn boundaries) and do NOT advance
2195
+ // the index. We track `hasOpenTurn` here to mirror projectDelta's open-
2196
+ // turn state machine exactly. Initial state comes from `priorCurrentTurn`
2197
+ // (set if a turn straddled the prior slice boundary).
2198
+ let nextTurnIndex = input.priorNextTurnIndex ?? 1;
2199
+ let hasOpenTurn = input.priorCurrentTurn !== undefined;
2200
+ // Anthropic msg_id dedup — must mirror projectDelta exactly so file_path
2201
+ // counts / distinct paths don't pick up duplicated assistant lines (the
2202
+ // same API response can be persisted on multiple JSONL lines; see
2203
+ // MessageBody.id docstring).
2204
+ const seenAssistantMessageIds = new Set(input.priorSeenAssistantMessageIds ?? []);
2205
+ const newAssistantMessageIdsThisSlice = [];
2206
+ for (const line of input.lines) {
2207
+ const lineTsMs = tsMs(line.timestamp);
2208
+ if (lineTsMs !== null) {
2209
+ if (priorTsMs !== null && isHumanTextUser(line)) {
2210
+ const gap = lineTsMs - priorTsMs;
2211
+ if (gap > 0) {
2212
+ idleMsDelta += gap;
2213
+ }
2214
+ }
2215
+ priorTsMs = lineTsMs;
2216
+ }
2217
+ if (isHumanTextUser(line)) {
2218
+ const isContinuation = line.isMeta === true && hasOpenTurn;
2219
+ if (!isContinuation) {
2220
+ nextTurnIndex += 1;
2221
+ hasOpenTurn = true;
2222
+ }
2223
+ // (continuation: hasOpenTurn stays true, nextTurnIndex unchanged)
2224
+ }
2225
+ // Track latest assistant timestamp regardless of content blocks —
2226
+ // we only care that there WAS an assistant turn here.
2227
+ if (line.type === "assistant") {
2228
+ if (lineTsMs !== null) {
2229
+ if (lastAssistantTsMs === undefined || lineTsMs > lastAssistantTsMs) {
2230
+ lastAssistantTsMs = lineTsMs;
2231
+ }
2232
+ }
2233
+ }
2234
+ if (line.type !== "assistant" || line.message === undefined) {
2235
+ continue;
2236
+ }
2237
+ // Apply the same msg_id dedup as projectDelta so distinct/file_path
2238
+ // counts stay consistent. We do this AFTER the latest-ts update
2239
+ // because timestamp tracking is harmless to repeat (same ts).
2240
+ const msgIdInternal = line.message.id;
2241
+ if (typeof msgIdInternal === "string" && msgIdInternal.length > 0) {
2242
+ if (seenAssistantMessageIds.has(msgIdInternal)) {
2243
+ continue;
2244
+ }
2245
+ seenAssistantMessageIds.add(msgIdInternal);
2246
+ newAssistantMessageIdsThisSlice.push(msgIdInternal);
2247
+ }
2248
+ const content = line.message.content;
2249
+ if (!Array.isArray(content)) {
2250
+ continue;
2251
+ }
2252
+ for (const blk of content) {
2253
+ if (blk === null || typeof blk !== "object") {
2254
+ continue;
2255
+ }
2256
+ const block = blk;
2257
+ if (block.type !== "tool_use" || typeof block.name !== "string") {
2258
+ continue;
2259
+ }
2260
+ if (block.name !== "Edit" && block.name !== "Write") {
2261
+ continue;
2262
+ }
2263
+ const fp = block.input?.file_path;
2264
+ if (typeof fp === "string" && fp.length > 0) {
2265
+ distinct.add(fp);
2266
+ filePathChangeCounts[fp] = (filePathChangeCounts[fp] ?? 0) + 1;
2267
+ }
2268
+ }
2269
+ }
2270
+ return {
2271
+ file_path_change_counts: filePathChangeCounts,
2272
+ distinct_file_paths_seen: Array.from(distinct).sort(),
2273
+ last_assistant_ts_ms: lastAssistantTsMs,
2274
+ idle_ms_delta: idleMsDelta,
2275
+ next_turn_index: nextTurnIndex,
2276
+ ...(newAssistantMessageIdsThisSlice.length > 0
2277
+ ? { new_assistant_message_ids: newAssistantMessageIdsThisSlice }
2278
+ : {}),
2279
+ };
2280
+ }
2281
+ //# sourceMappingURL=projection.js.map