@ironbee-ai/cli 0.6.1 → 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.
- package/CHANGELOG.md +12 -0
- package/README.md +167 -39
- package/dist/analysis/code-changes.js +3 -3
- package/dist/analysis/code-changes.js.map +1 -1
- package/dist/analysis/cross-session.js.map +1 -1
- package/dist/analysis/fix-effectiveness.js.map +1 -1
- package/dist/analysis/time-analysis.js.map +1 -1
- package/dist/analysis/verdict-details.js.map +1 -1
- package/dist/analysis/verification-quality.js.map +1 -1
- package/dist/analytics/classifier.d.ts +99 -0
- package/dist/analytics/classifier.d.ts.map +1 -0
- package/dist/analytics/classifier.js +380 -0
- package/dist/analytics/classifier.js.map +1 -0
- package/dist/analytics/emit.d.ts +67 -0
- package/dist/analytics/emit.d.ts.map +1 -0
- package/dist/analytics/emit.js +901 -0
- package/dist/analytics/emit.js.map +1 -0
- package/dist/analytics/errors.d.ts +33 -0
- package/dist/analytics/errors.d.ts.map +1 -0
- package/dist/analytics/errors.js +93 -0
- package/dist/analytics/errors.js.map +1 -0
- package/dist/analytics/hook-trigger.d.ts +39 -0
- package/dist/analytics/hook-trigger.d.ts.map +1 -0
- package/dist/analytics/hook-trigger.js +127 -0
- package/dist/analytics/hook-trigger.js.map +1 -0
- package/dist/analytics/log.d.ts +44 -0
- package/dist/analytics/log.d.ts.map +1 -0
- package/dist/analytics/log.js +158 -0
- package/dist/analytics/log.js.map +1 -0
- package/dist/analytics/merge.d.ts +40 -0
- package/dist/analytics/merge.d.ts.map +1 -0
- package/dist/analytics/merge.js +527 -0
- package/dist/analytics/merge.js.map +1 -0
- package/dist/analytics/pricing.d.ts +149 -0
- package/dist/analytics/pricing.d.ts.map +1 -0
- package/dist/analytics/pricing.js +179 -0
- package/dist/analytics/pricing.js.map +1 -0
- package/dist/analytics/projection.d.ts +356 -0
- package/dist/analytics/projection.d.ts.map +1 -0
- package/dist/analytics/projection.js +2281 -0
- package/dist/analytics/projection.js.map +1 -0
- package/dist/analytics/spawn.d.ts +28 -0
- package/dist/analytics/spawn.d.ts.map +1 -0
- package/dist/analytics/spawn.js +57 -0
- package/dist/analytics/spawn.js.map +1 -0
- package/dist/analytics/state.d.ts +58 -0
- package/dist/analytics/state.d.ts.map +1 -0
- package/dist/analytics/state.js +329 -0
- package/dist/analytics/state.js.map +1 -0
- package/dist/analytics/transcript.d.ts +150 -0
- package/dist/analytics/transcript.d.ts.map +1 -0
- package/dist/analytics/transcript.js +276 -0
- package/dist/analytics/transcript.js.map +1 -0
- package/dist/analytics/types.d.ts +875 -0
- package/dist/analytics/types.d.ts.map +1 -0
- package/dist/analytics/types.js +31 -0
- package/dist/analytics/types.js.map +1 -0
- package/dist/clients/base.d.ts +21 -2
- package/dist/clients/base.d.ts.map +1 -1
- package/dist/clients/claude/commands/ironbee-verify.md +15 -7
- package/dist/clients/claude/fragments/command-verify.node.md +33 -0
- package/dist/clients/claude/fragments/rule.node.md +29 -0
- package/dist/clients/claude/fragments/skill.node.md +77 -0
- package/dist/clients/claude/hooks/activity-end.d.ts +13 -0
- package/dist/clients/claude/hooks/activity-end.d.ts.map +1 -0
- package/dist/clients/claude/hooks/activity-end.js +42 -0
- package/dist/clients/claude/hooks/activity-end.js.map +1 -0
- package/dist/clients/claude/hooks/clear-verdict.d.ts +9 -4
- package/dist/clients/claude/hooks/clear-verdict.d.ts.map +1 -1
- package/dist/clients/claude/hooks/clear-verdict.js +50 -12
- package/dist/clients/claude/hooks/clear-verdict.js.map +1 -1
- package/dist/clients/claude/hooks/require-verdict.d.ts +8 -3
- package/dist/clients/claude/hooks/require-verdict.d.ts.map +1 -1
- package/dist/clients/claude/hooks/require-verdict.js +17 -6
- package/dist/clients/claude/hooks/require-verdict.js.map +1 -1
- package/dist/clients/claude/hooks/require-verification.d.ts +7 -4
- package/dist/clients/claude/hooks/require-verification.d.ts.map +1 -1
- package/dist/clients/claude/hooks/require-verification.js +44 -22
- package/dist/clients/claude/hooks/require-verification.js.map +1 -1
- package/dist/clients/claude/hooks/session-end.d.ts.map +1 -1
- package/dist/clients/claude/hooks/session-end.js +17 -2
- package/dist/clients/claude/hooks/session-end.js.map +1 -1
- package/dist/clients/claude/hooks/session-start.d.ts.map +1 -1
- package/dist/clients/claude/hooks/session-start.js +2 -1
- package/dist/clients/claude/hooks/session-start.js.map +1 -1
- package/dist/clients/claude/hooks/track-action-monitor.d.ts +27 -0
- package/dist/clients/claude/hooks/track-action-monitor.d.ts.map +1 -0
- package/dist/clients/claude/hooks/track-action-monitor.js +126 -0
- package/dist/clients/claude/hooks/track-action-monitor.js.map +1 -0
- package/dist/clients/claude/hooks/track-action.d.ts.map +1 -1
- package/dist/clients/claude/hooks/track-action.js +29 -20
- package/dist/clients/claude/hooks/track-action.js.map +1 -1
- package/dist/clients/claude/hooks/verify-gate.d.ts.map +1 -1
- package/dist/clients/claude/hooks/verify-gate.js +18 -1
- package/dist/clients/claude/hooks/verify-gate.js.map +1 -1
- package/dist/clients/claude/index.d.ts +4 -1
- package/dist/clients/claude/index.d.ts.map +1 -1
- package/dist/clients/claude/index.js +185 -94
- package/dist/clients/claude/index.js.map +1 -1
- package/dist/clients/claude/rules/ironbee-verification.md +41 -33
- package/dist/clients/claude/skills/ironbee-verification.md +93 -76
- package/dist/clients/cursor/commands/ironbee-verify/SKILL.md +18 -10
- package/dist/clients/cursor/fragments/command-verify.node.md +33 -0
- package/dist/clients/cursor/fragments/rule.node.md +29 -0
- package/dist/clients/cursor/fragments/skill.node.md +77 -0
- package/dist/clients/cursor/hooks/activity-end.d.ts +14 -0
- package/dist/clients/cursor/hooks/activity-end.d.ts.map +1 -0
- package/dist/clients/cursor/hooks/activity-end.js +45 -0
- package/dist/clients/cursor/hooks/activity-end.js.map +1 -0
- package/dist/clients/cursor/hooks/clear-verdict.d.ts +13 -4
- package/dist/clients/cursor/hooks/clear-verdict.d.ts.map +1 -1
- package/dist/clients/cursor/hooks/clear-verdict.js +59 -14
- package/dist/clients/cursor/hooks/clear-verdict.js.map +1 -1
- package/dist/clients/cursor/hooks/require-verdict.d.ts +8 -3
- package/dist/clients/cursor/hooks/require-verdict.d.ts.map +1 -1
- package/dist/clients/cursor/hooks/require-verdict.js +17 -6
- package/dist/clients/cursor/hooks/require-verdict.js.map +1 -1
- package/dist/clients/cursor/hooks/require-verification.d.ts.map +1 -1
- package/dist/clients/cursor/hooks/require-verification.js +42 -16
- package/dist/clients/cursor/hooks/require-verification.js.map +1 -1
- package/dist/clients/cursor/hooks/session-end.d.ts.map +1 -1
- package/dist/clients/cursor/hooks/session-end.js +18 -2
- package/dist/clients/cursor/hooks/session-end.js.map +1 -1
- package/dist/clients/cursor/hooks/session-start.d.ts.map +1 -1
- package/dist/clients/cursor/hooks/session-start.js +2 -1
- package/dist/clients/cursor/hooks/session-start.js.map +1 -1
- package/dist/clients/cursor/hooks/track-action-monitor.d.ts +27 -0
- package/dist/clients/cursor/hooks/track-action-monitor.d.ts.map +1 -0
- package/dist/clients/cursor/hooks/track-action-monitor.js +133 -0
- package/dist/clients/cursor/hooks/track-action-monitor.js.map +1 -0
- package/dist/clients/cursor/hooks/track-action.d.ts.map +1 -1
- package/dist/clients/cursor/hooks/track-action.js +51 -23
- package/dist/clients/cursor/hooks/track-action.js.map +1 -1
- package/dist/clients/cursor/hooks/verify-gate.d.ts.map +1 -1
- package/dist/clients/cursor/hooks/verify-gate.js +14 -1
- package/dist/clients/cursor/hooks/verify-gate.js.map +1 -1
- package/dist/clients/cursor/index.d.ts +4 -1
- package/dist/clients/cursor/index.d.ts.map +1 -1
- package/dist/clients/cursor/index.js +131 -65
- package/dist/clients/cursor/index.js.map +1 -1
- package/dist/clients/cursor/rules/ironbee-verification.mdc +37 -29
- package/dist/clients/cursor/skills/ironbee-verification.md +93 -76
- package/dist/clients/registry.d.ts +14 -0
- package/dist/clients/registry.d.ts.map +1 -1
- package/dist/clients/registry.js +34 -0
- package/dist/clients/registry.js.map +1 -1
- package/dist/commands/analyze.d.ts.map +1 -1
- package/dist/commands/analyze.js +40 -0
- package/dist/commands/analyze.js.map +1 -1
- package/dist/commands/backend-toggle.d.ts +45 -0
- package/dist/commands/backend-toggle.d.ts.map +1 -0
- package/dist/commands/backend-toggle.js +192 -0
- package/dist/commands/backend-toggle.js.map +1 -0
- package/dist/commands/disable-backend.d.ts +14 -0
- package/dist/commands/disable-backend.d.ts.map +1 -0
- package/dist/commands/disable-backend.js +34 -0
- package/dist/commands/disable-backend.js.map +1 -0
- package/dist/commands/disable-verification.d.ts +16 -0
- package/dist/commands/disable-verification.d.ts.map +1 -0
- package/dist/commands/disable-verification.js +36 -0
- package/dist/commands/disable-verification.js.map +1 -0
- package/dist/commands/enable-backend.d.ts +15 -0
- package/dist/commands/enable-backend.d.ts.map +1 -0
- package/dist/commands/enable-backend.js +35 -0
- package/dist/commands/enable-backend.js.map +1 -0
- package/dist/commands/enable-verification.d.ts +14 -0
- package/dist/commands/enable-verification.d.ts.map +1 -0
- package/dist/commands/enable-verification.js +34 -0
- package/dist/commands/enable-verification.js.map +1 -0
- package/dist/commands/hook.d.ts.map +1 -1
- package/dist/commands/hook.js +60 -0
- package/dist/commands/hook.js.map +1 -1
- package/dist/commands/import.d.ts +39 -0
- package/dist/commands/import.d.ts.map +1 -0
- package/dist/commands/import.js +369 -0
- package/dist/commands/import.js.map +1 -0
- package/dist/commands/install.d.ts.map +1 -1
- package/dist/commands/install.js +15 -20
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/process-analytics.d.ts +18 -0
- package/dist/commands/process-analytics.d.ts.map +1 -0
- package/dist/commands/process-analytics.js +57 -0
- package/dist/commands/process-analytics.js.map +1 -0
- package/dist/commands/queue.d.ts +2 -3
- package/dist/commands/queue.d.ts.map +1 -1
- package/dist/commands/queue.js +2 -3
- package/dist/commands/queue.js.map +1 -1
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +29 -1
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/verification-toggle.d.ts +47 -0
- package/dist/commands/verification-toggle.d.ts.map +1 -0
- package/dist/commands/verification-toggle.js +113 -0
- package/dist/commands/verification-toggle.js.map +1 -0
- package/dist/commands/verify.d.ts.map +1 -1
- package/dist/commands/verify.js +28 -0
- package/dist/commands/verify.js.map +1 -1
- package/dist/hooks/core/actions.d.ts +77 -70
- package/dist/hooks/core/actions.d.ts.map +1 -1
- package/dist/hooks/core/actions.js +45 -30
- package/dist/hooks/core/actions.js.map +1 -1
- package/dist/hooks/core/activity-end.d.ts +20 -0
- package/dist/hooks/core/activity-end.d.ts.map +1 -0
- package/dist/hooks/core/activity-end.js +23 -0
- package/dist/hooks/core/activity-end.js.map +1 -0
- package/dist/hooks/core/file-diff.d.ts +19 -0
- package/dist/hooks/core/file-diff.d.ts.map +1 -0
- package/dist/hooks/core/file-diff.js +39 -0
- package/dist/hooks/core/file-diff.js.map +1 -0
- package/dist/hooks/core/required-tools.d.ts +30 -0
- package/dist/hooks/core/required-tools.d.ts.map +1 -0
- package/dist/hooks/core/required-tools.js +70 -0
- package/dist/hooks/core/required-tools.js.map +1 -0
- package/dist/hooks/core/session-state.d.ts +12 -3
- package/dist/hooks/core/session-state.d.ts.map +1 -1
- package/dist/hooks/core/session-state.js +59 -0
- package/dist/hooks/core/session-state.js.map +1 -1
- package/dist/hooks/core/submit-verdict.d.ts.map +1 -1
- package/dist/hooks/core/submit-verdict.js +16 -12
- package/dist/hooks/core/submit-verdict.js.map +1 -1
- package/dist/hooks/core/tool-use-stash.d.ts +41 -0
- package/dist/hooks/core/tool-use-stash.d.ts.map +1 -0
- package/dist/hooks/core/tool-use-stash.js +82 -0
- package/dist/hooks/core/tool-use-stash.js.map +1 -0
- package/dist/hooks/core/verify-gate.d.ts +17 -3
- package/dist/hooks/core/verify-gate.d.ts.map +1 -1
- package/dist/hooks/core/verify-gate.js +315 -119
- package/dist/hooks/core/verify-gate.js.map +1 -1
- package/dist/import/claude/analytics-runner.d.ts +42 -0
- package/dist/import/claude/analytics-runner.d.ts.map +1 -0
- package/dist/import/claude/analytics-runner.js +213 -0
- package/dist/import/claude/analytics-runner.js.map +1 -0
- package/dist/import/claude/discovery.d.ts +22 -0
- package/dist/import/claude/discovery.d.ts.map +1 -0
- package/dist/import/claude/discovery.js +197 -0
- package/dist/import/claude/discovery.js.map +1 -0
- package/dist/import/claude/encoding.d.ts +50 -0
- package/dist/import/claude/encoding.d.ts.map +1 -0
- package/dist/import/claude/encoding.js +110 -0
- package/dist/import/claude/encoding.js.map +1 -0
- package/dist/import/claude/events/file-change.d.ts +28 -0
- package/dist/import/claude/events/file-change.d.ts.map +1 -0
- package/dist/import/claude/events/file-change.js +112 -0
- package/dist/import/claude/events/file-change.js.map +1 -0
- package/dist/import/claude/events/tool-call.d.ts +61 -0
- package/dist/import/claude/events/tool-call.d.ts.map +1 -0
- package/dist/import/claude/events/tool-call.js +119 -0
- package/dist/import/claude/events/tool-call.js.map +1 -0
- package/dist/import/claude/runner.d.ts +31 -0
- package/dist/import/claude/runner.d.ts.map +1 -0
- package/dist/import/claude/runner.js +280 -0
- package/dist/import/claude/runner.js.map +1 -0
- package/dist/import/claude/summary.d.ts +23 -0
- package/dist/import/claude/summary.d.ts.map +1 -0
- package/dist/import/claude/summary.js +186 -0
- package/dist/import/claude/summary.js.map +1 -0
- package/dist/import/claude/transcript-walk.d.ts +52 -0
- package/dist/import/claude/transcript-walk.d.ts.map +1 -0
- package/dist/import/claude/transcript-walk.js +187 -0
- package/dist/import/claude/transcript-walk.js.map +1 -0
- package/dist/import/concurrent-pool.d.ts +45 -0
- package/dist/import/concurrent-pool.d.ts.map +1 -0
- package/dist/import/concurrent-pool.js +95 -0
- package/dist/import/concurrent-pool.js.map +1 -0
- package/dist/import/emitter.d.ts +29 -0
- package/dist/import/emitter.d.ts.map +1 -0
- package/dist/import/emitter.js +66 -0
- package/dist/import/emitter.js.map +1 -0
- package/dist/import/events/activity.d.ts +23 -0
- package/dist/import/events/activity.d.ts.map +1 -0
- package/dist/import/events/activity.js +45 -0
- package/dist/import/events/activity.js.map +1 -0
- package/dist/import/events/session.d.ts +24 -0
- package/dist/import/events/session.d.ts.map +1 -0
- package/dist/import/events/session.js +47 -0
- package/dist/import/events/session.js.map +1 -0
- package/dist/import/filter.d.ts +47 -0
- package/dist/import/filter.d.ts.map +1 -0
- package/dist/import/filter.js +90 -0
- package/dist/import/filter.js.map +1 -0
- package/dist/import/ids.d.ts +56 -0
- package/dist/import/ids.d.ts.map +1 -0
- package/dist/import/ids.js +87 -0
- package/dist/import/ids.js.map +1 -0
- package/dist/import/index.d.ts +29 -0
- package/dist/import/index.d.ts.map +1 -0
- package/dist/import/index.js +52 -0
- package/dist/import/index.js.map +1 -0
- package/dist/import/marker.d.ts +20 -0
- package/dist/import/marker.d.ts.map +1 -0
- package/dist/import/marker.js +71 -0
- package/dist/import/marker.js.map +1 -0
- package/dist/import/pipeline.d.ts +41 -0
- package/dist/import/pipeline.d.ts.map +1 -0
- package/dist/import/pipeline.js +47 -0
- package/dist/import/pipeline.js.map +1 -0
- package/dist/import/progress.d.ts +20 -0
- package/dist/import/progress.d.ts.map +1 -0
- package/dist/import/progress.js +69 -0
- package/dist/import/progress.js.map +1 -0
- package/dist/import/skip.d.ts +13 -0
- package/dist/import/skip.d.ts.map +1 -0
- package/dist/import/skip.js +24 -0
- package/dist/import/skip.js.map +1 -0
- package/dist/import/types.d.ts +125 -0
- package/dist/import/types.d.ts.map +1 -0
- package/dist/import/types.js +28 -0
- package/dist/import/types.js.map +1 -0
- package/dist/index.js +21 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/collector.d.ts +29 -3
- package/dist/lib/collector.d.ts.map +1 -1
- package/dist/lib/collector.js +118 -8
- package/dist/lib/collector.js.map +1 -1
- package/dist/lib/config.d.ts +240 -83
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +482 -89
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/event.d.ts +72 -0
- package/dist/lib/event.d.ts.map +1 -0
- package/dist/lib/event.js +42 -0
- package/dist/lib/event.js.map +1 -0
- package/dist/lib/gitignore.d.ts +21 -0
- package/dist/lib/gitignore.d.ts.map +1 -0
- package/dist/lib/gitignore.js +54 -0
- package/dist/lib/gitignore.js.map +1 -0
- package/dist/lib/runtime-section.d.ts +118 -0
- package/dist/lib/runtime-section.d.ts.map +1 -0
- package/dist/lib/runtime-section.js +256 -0
- package/dist/lib/runtime-section.js.map +1 -0
- package/dist/lib/telemetry.d.ts +1 -1
- package/dist/lib/telemetry.d.ts.map +1 -1
- package/dist/lib/telemetry.js +4 -1
- package/dist/lib/telemetry.js.map +1 -1
- package/dist/queue/dead-letter.d.ts +5 -1
- package/dist/queue/dead-letter.d.ts.map +1 -1
- package/dist/queue/dead-letter.js +5 -1
- package/dist/queue/dead-letter.js.map +1 -1
- package/dist/queue/drain.d.ts +3 -2
- package/dist/queue/drain.d.ts.map +1 -1
- package/dist/queue/drain.js +3 -2
- package/dist/queue/drain.js.map +1 -1
- package/dist/queue/flush.d.ts +28 -12
- package/dist/queue/flush.d.ts.map +1 -1
- package/dist/queue/flush.js +43 -18
- package/dist/queue/flush.js.map +1 -1
- package/dist/queue/handlers/send-event.d.ts.map +1 -1
- package/dist/queue/handlers/send-event.js.map +1 -1
- package/dist/queue/index.d.ts +1 -2
- package/dist/queue/index.d.ts.map +1 -1
- package/dist/queue/index.js +2 -2
- package/dist/queue/index.js.map +1 -1
- package/dist/queue/paths.d.ts +4 -2
- package/dist/queue/paths.d.ts.map +1 -1
- package/dist/queue/paths.js +4 -2
- package/dist/queue/paths.js.map +1 -1
- package/dist/queue/process-file.d.ts +5 -1
- package/dist/queue/process-file.d.ts.map +1 -1
- package/dist/queue/process-file.js +5 -1
- package/dist/queue/process-file.js.map +1 -1
- package/dist/queue/snapshot.d.ts +4 -1
- package/dist/queue/snapshot.d.ts.map +1 -1
- package/dist/queue/snapshot.js +4 -1
- package/dist/queue/snapshot.js.map +1 -1
- package/dist/queue/spawn.d.ts +1 -3
- package/dist/queue/spawn.d.ts.map +1 -1
- package/dist/queue/spawn.js +1 -3
- package/dist/queue/spawn.js.map +1 -1
- package/dist/queue/submit.d.ts +6 -1
- package/dist/queue/submit.d.ts.map +1 -1
- package/dist/queue/submit.js +6 -1
- package/dist/queue/submit.js.map +1 -1
- package/dist/queue/types.d.ts +5 -1
- package/dist/queue/types.d.ts.map +1 -1
- package/dist/queue/types.js +5 -1
- package/dist/queue/types.js.map +1 -1
- package/dist/queue/worker-log.d.ts +3 -1
- package/dist/queue/worker-log.d.ts.map +1 -1
- package/dist/queue/worker-log.js +3 -1
- package/dist/queue/worker-log.js.map +1 -1
- package/package.json +3 -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
|