sidekick-shared 0.27.6 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (269) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +58 -18
  3. package/dist/browser.d.ts +13 -2
  4. package/dist/browser.js +30 -2
  5. package/dist/claudePaths.d.ts +11 -0
  6. package/dist/claudePaths.js +61 -0
  7. package/dist/claudeProfiles.d.ts +2 -9
  8. package/dist/claudeProfiles.js +10 -25
  9. package/dist/codexPaths.d.ts +22 -0
  10. package/dist/codexPaths.js +67 -0
  11. package/dist/codexProfiles.d.ts +1 -4
  12. package/dist/codexProfiles.js +5 -43
  13. package/dist/doctor.js +2 -2
  14. package/dist/esm/accountChangeSignal.js +15 -0
  15. package/dist/esm/accountChanges.js +152 -0
  16. package/dist/esm/accountHealth.js +288 -0
  17. package/dist/esm/accountLaunch.js +145 -0
  18. package/dist/esm/accountLease.js +60 -0
  19. package/dist/esm/accountManager.js +606 -0
  20. package/dist/esm/accountRegistry.js +214 -0
  21. package/dist/esm/accountStatus.js +51 -0
  22. package/dist/esm/accountSwitch.js +74 -0
  23. package/dist/esm/accountSync.js +156 -0
  24. package/dist/esm/accountSyncTypes.js +4 -0
  25. package/dist/esm/accounts.js +1291 -0
  26. package/dist/esm/aggregation/EventAggregator.js +2034 -0
  27. package/dist/esm/aggregation/FrequencyTracker.js +69 -0
  28. package/dist/esm/aggregation/HeatmapTracker.js +89 -0
  29. package/dist/esm/aggregation/PatternExtractor.js +168 -0
  30. package/dist/esm/aggregation/costProvenance.js +31 -0
  31. package/dist/esm/aggregation/snapshot.js +161 -0
  32. package/dist/esm/aggregation/types.js +5 -0
  33. package/dist/esm/analytics/codeImpact.js +18 -0
  34. package/dist/esm/analytics/compactionLedger.js +37 -0
  35. package/dist/esm/analytics/qualityScore.js +112 -0
  36. package/dist/esm/autoSwitch.js +185 -0
  37. package/dist/esm/browser.js +34 -0
  38. package/dist/esm/claudeCredentialDoctor.js +239 -0
  39. package/dist/esm/claudeCredentialLedger.js +91 -0
  40. package/dist/esm/claudeCredentials.js +111 -0
  41. package/dist/esm/claudeLiveGuard.js +43 -0
  42. package/dist/esm/claudePaths.js +22 -0
  43. package/dist/esm/claudeProfiles.js +142 -0
  44. package/dist/esm/claudeRefreshLock.js +106 -0
  45. package/dist/esm/codexAuth.js +101 -0
  46. package/dist/esm/codexCredentialDoctor.js +265 -0
  47. package/dist/esm/codexCredentialStore.js +164 -0
  48. package/dist/esm/codexKeepAlive.js +112 -0
  49. package/dist/esm/codexPaths.js +172 -0
  50. package/dist/esm/codexProfiles.js +1251 -0
  51. package/dist/esm/codexQuota.js +564 -0
  52. package/dist/esm/codexQuotaWatcher.js +363 -0
  53. package/dist/esm/context/composer.js +71 -0
  54. package/dist/esm/context/sessionContext.js +546 -0
  55. package/dist/esm/credentialIO.js +283 -0
  56. package/dist/esm/credentials.js +49 -0
  57. package/dist/esm/doctor.js +305 -0
  58. package/dist/esm/ensureDefaultAccounts.js +62 -0
  59. package/dist/esm/errorHistory.js +56 -0
  60. package/dist/esm/extractors/errorTaxonomy.js +43 -0
  61. package/dist/esm/extractors/gatherAssets.js +32 -0
  62. package/dist/esm/extractors/sessionAssets.js +176 -0
  63. package/dist/esm/extractors/sources/claudeAssets.js +158 -0
  64. package/dist/esm/extractors/sources/codexAssets.js +234 -0
  65. package/dist/esm/extractors/tokenUsage.js +41 -0
  66. package/dist/esm/extractors/toolCall.js +142 -0
  67. package/dist/esm/failingTools.js +65 -0
  68. package/dist/esm/fileHead.js +27 -0
  69. package/dist/esm/formatters/eventHighlighter.js +332 -0
  70. package/dist/esm/formatters/noiseClassifier.js +209 -0
  71. package/dist/esm/formatters/sessionDump.js +328 -0
  72. package/dist/esm/formatters/toolSummary.js +226 -0
  73. package/dist/esm/formatting.js +82 -0
  74. package/dist/esm/handoffUrl.js +23 -0
  75. package/dist/esm/historicalStore.js +305 -0
  76. package/dist/esm/humanPrompt.js +88 -0
  77. package/dist/esm/index.js +213 -0
  78. package/dist/esm/keepAlive.js +189 -0
  79. package/dist/esm/liveCredentialGuard.js +33 -0
  80. package/dist/esm/modelAliases.js +51 -0
  81. package/dist/esm/modelCatalog.js +139 -0
  82. package/dist/esm/modelContext.js +280 -0
  83. package/dist/esm/modelInfo.js +832 -0
  84. package/dist/esm/multiProviderQuotaService.js +450 -0
  85. package/dist/esm/node.js +46 -0
  86. package/dist/esm/observedContextWindows.js +165 -0
  87. package/dist/esm/observedSessionCollector.js +939 -0
  88. package/dist/esm/parsers/changelogParser.js +71 -0
  89. package/dist/esm/parsers/codexHistory.js +89 -0
  90. package/dist/esm/parsers/codexParser.js +906 -0
  91. package/dist/esm/parsers/debugLogParser.js +126 -0
  92. package/dist/esm/parsers/jsonl.js +66 -0
  93. package/dist/esm/parsers/mcpToolName.js +28 -0
  94. package/dist/esm/parsers/openCodeParser.js +625 -0
  95. package/dist/esm/parsers/planExtractor.js +335 -0
  96. package/dist/esm/parsers/sessionActivityDetector.js +246 -0
  97. package/dist/esm/parsers/sessionPathResolver.js +713 -0
  98. package/dist/esm/parsers/subagentScanner.js +360 -0
  99. package/dist/esm/parsers/subagentStatsCache.js +45 -0
  100. package/dist/esm/parsers/subagentTraceParser.js +325 -0
  101. package/dist/esm/paths.js +134 -0
  102. package/dist/esm/peakHours.js +194 -0
  103. package/dist/esm/phrases.js +1330 -0
  104. package/dist/esm/pricingCatalog.js +247 -0
  105. package/dist/esm/processDetection.js +200 -0
  106. package/dist/esm/projectMigration.js +59 -0
  107. package/dist/esm/promptHistory.js +1221 -0
  108. package/dist/esm/providerFailure.js +362 -0
  109. package/dist/esm/providerIds.js +6 -0
  110. package/dist/esm/providerQuota.js +1 -0
  111. package/dist/esm/providerServiceStatus.js +145 -0
  112. package/dist/esm/providerServiceStatusTypes.js +1 -0
  113. package/dist/esm/providerStatus.js +117 -0
  114. package/dist/esm/providers/claudeCode.js +575 -0
  115. package/dist/esm/providers/claudeListing.js +121 -0
  116. package/dist/esm/providers/codex.js +1034 -0
  117. package/dist/esm/providers/codexDatabase.js +297 -0
  118. package/dist/esm/providers/codexRollouts.js +215 -0
  119. package/dist/esm/providers/detect.js +185 -0
  120. package/dist/esm/providers/diagnostics.js +83 -0
  121. package/dist/esm/providers/directoryListingCache.js +206 -0
  122. package/dist/esm/providers/factory.js +54 -0
  123. package/dist/esm/providers/openCode.js +2008 -0
  124. package/dist/esm/providers/openCodeDatabase.js +419 -0
  125. package/dist/esm/providers/rolloutWalker.js +252 -0
  126. package/dist/esm/providers/types.js +12 -0
  127. package/dist/esm/quota.js +182 -0
  128. package/dist/esm/quotaHistory.js +364 -0
  129. package/dist/esm/quotaPoller.js +230 -0
  130. package/dist/esm/quotaPresentation.js +97 -0
  131. package/dist/esm/quotaResolver.js +231 -0
  132. package/dist/esm/quotaSnapshots.js +130 -0
  133. package/dist/esm/quotaThresholds.js +85 -0
  134. package/dist/esm/readers/decisions.js +24 -0
  135. package/dist/esm/readers/handoff.js +20 -0
  136. package/dist/esm/readers/helpers.js +27 -0
  137. package/dist/esm/readers/history.js +9 -0
  138. package/dist/esm/readers/notes.js +48 -0
  139. package/dist/esm/readers/plans.js +225 -0
  140. package/dist/esm/readers/tasks.js +19 -0
  141. package/dist/esm/report/htmlHelpers.js +280 -0
  142. package/dist/esm/report/htmlReportGenerator.js +704 -0
  143. package/dist/esm/report/index.js +7 -0
  144. package/dist/esm/report/logo.js +2 -0
  145. package/dist/esm/report/openBrowser.js +19 -0
  146. package/dist/esm/report/sessionReportInputs.js +33 -0
  147. package/dist/esm/report/transcriptParser.js +83 -0
  148. package/dist/esm/report/types.js +4 -0
  149. package/dist/esm/schemas/accountManager.js +204 -0
  150. package/dist/esm/schemas/accountStatus.js +25 -0
  151. package/dist/esm/schemas/assistantTurn.js +74 -0
  152. package/dist/esm/schemas/index.js +25 -0
  153. package/dist/esm/schemas/observedSessionV1.js +77 -0
  154. package/dist/esm/schemas/providerFailure.js +37 -0
  155. package/dist/esm/schemas/quota.js +132 -0
  156. package/dist/esm/schemas/quotaHistory.js +40 -0
  157. package/dist/esm/schemas/sessionEvent.js +216 -0
  158. package/dist/esm/schemas/stateFile.js +75 -0
  159. package/dist/esm/schemas/tokenEstimation.js +7 -0
  160. package/dist/esm/schemas/transcript.js +86 -0
  161. package/dist/esm/schemas/usageNormalization.js +38 -0
  162. package/dist/esm/search/advancedFilter.js +197 -0
  163. package/dist/esm/search/sessionSearch.js +66 -0
  164. package/dist/esm/sessionFingerprint.js +40 -0
  165. package/dist/esm/sessionMonitor.js +198 -0
  166. package/dist/esm/sessionPreviews.js +522 -0
  167. package/dist/esm/sessionPromptHistory.js +254 -0
  168. package/dist/esm/sessionPromptSignals.js +364 -0
  169. package/dist/esm/sessionStats.js +160 -0
  170. package/dist/esm/sessionTokenTotals.js +98 -0
  171. package/dist/esm/sessionTranscripts.js +49 -0
  172. package/dist/esm/stateFile.js +119 -0
  173. package/dist/esm/statusline/BurnRateCalculator.js +40 -0
  174. package/dist/esm/statusline/claudeStatuslinePayload.js +148 -0
  175. package/dist/esm/statusline/formatter.js +122 -0
  176. package/dist/esm/statusline/index.js +8 -0
  177. package/dist/esm/terminalSync.js +95 -0
  178. package/dist/esm/testing/credentialGuard.js +197 -0
  179. package/dist/esm/testing/index.js +12 -0
  180. package/dist/esm/testing/sessionLogFixtures.js +128 -0
  181. package/dist/esm/tokenEstimation.js +67 -0
  182. package/dist/esm/tokenSummary.js +113 -0
  183. package/dist/esm/toolCategory.js +82 -0
  184. package/dist/esm/transcript.js +257 -0
  185. package/dist/esm/turns/assistantTurn.js +373 -0
  186. package/dist/esm/types/codex.js +23 -0
  187. package/dist/esm/types/decisionLog.js +5 -0
  188. package/dist/esm/types/historicalData.js +47 -0
  189. package/dist/esm/types/knowledgeNote.js +15 -0
  190. package/dist/esm/types/observedSessionV1.js +257 -0
  191. package/dist/esm/types/opencode.js +12 -0
  192. package/dist/esm/types/plan.js +7 -0
  193. package/dist/esm/types/sessionEvent.js +10 -0
  194. package/dist/esm/types/taskPersistence.js +12 -0
  195. package/dist/esm/usage/billingBlocks.js +148 -0
  196. package/dist/esm/usage/claudeUsageDedupe.js +189 -0
  197. package/dist/esm/usage/importSessionHistory.js +84 -0
  198. package/dist/esm/usage/subagentUsage.js +18 -0
  199. package/dist/esm/usage/usageEvents.js +402 -0
  200. package/dist/esm/usage/usageReports.js +202 -0
  201. package/dist/esm/usageNormalization.js +95 -0
  202. package/dist/esm/watchers/eventBridge.js +219 -0
  203. package/dist/esm/watchers/factory.js +63 -0
  204. package/dist/esm/watchers/jsonlTail.js +289 -0
  205. package/dist/esm/watchers/jsonlWatcher.js +435 -0
  206. package/dist/esm/watchers/providerReaderWatcher.js +118 -0
  207. package/dist/esm/watchers/sqliteWatcher.js +285 -0
  208. package/dist/esm/watchers/types.js +4 -0
  209. package/dist/esm/workspaceRoots.js +56 -0
  210. package/dist/esm/writers/atomic.js +375 -0
  211. package/dist/esm/writers/decisions.js +51 -0
  212. package/dist/esm/writers/notes.js +46 -0
  213. package/dist/esm/writers/tasks.js +63 -0
  214. package/dist/esm/zaiQuota.js +323 -0
  215. package/dist/esm/zaiQuotaApi.js +271 -0
  216. package/dist/esm/zaiQuotaWatcher.js +265 -0
  217. package/dist/fileHead.d.ts +22 -0
  218. package/dist/fileHead.js +33 -0
  219. package/dist/index.d.ts +16 -7
  220. package/dist/index.js +49 -15
  221. package/dist/node.d.ts +13 -1
  222. package/dist/node.js +43 -2
  223. package/dist/parsers/codexHistory.js +2 -2
  224. package/dist/parsers/codexParser.d.ts +12 -0
  225. package/dist/parsers/codexParser.js +34 -12
  226. package/dist/parsers/debugLogParser.js +2 -2
  227. package/dist/parsers/sessionPathResolver.js +5 -4
  228. package/dist/promptHistory.d.ts +32 -0
  229. package/dist/promptHistory.js +115 -59
  230. package/dist/providerFailure.d.ts +12 -6
  231. package/dist/providerFailure.js +45 -0
  232. package/dist/providers/claudeCode.js +7 -99
  233. package/dist/providers/claudeListing.d.ts +22 -0
  234. package/dist/providers/claudeListing.js +158 -0
  235. package/dist/providers/codex.d.ts +3 -1
  236. package/dist/providers/codex.js +26 -227
  237. package/dist/providers/codexRollouts.d.ts +44 -0
  238. package/dist/providers/codexRollouts.js +261 -0
  239. package/dist/providers/detect.js +4 -4
  240. package/dist/readers/plans.js +3 -3
  241. package/dist/schemas/index.d.ts +2 -0
  242. package/dist/schemas/index.js +7 -2
  243. package/dist/schemas/providerFailure.d.ts +154 -0
  244. package/dist/schemas/providerFailure.js +40 -0
  245. package/dist/sessionPromptHistory.d.ts +12 -7
  246. package/dist/sessionPromptHistory.js +15 -3
  247. package/dist/sessionPromptSignals.d.ts +9 -0
  248. package/dist/sessionPromptSignals.js +13 -0
  249. package/dist/testing/credentialGuard.d.ts +23 -0
  250. package/dist/testing/credentialGuard.js +233 -0
  251. package/dist/testing/index.d.ts +14 -0
  252. package/dist/testing/index.js +22 -0
  253. package/dist/testing/sessionLogFixtures.d.ts +28 -0
  254. package/dist/testing/sessionLogFixtures.js +169 -0
  255. package/dist/tokenEstimation.d.ts +2 -0
  256. package/dist/tokenEstimation.js +21 -11
  257. package/dist/toolCategory.d.ts +18 -0
  258. package/dist/toolCategory.js +86 -0
  259. package/dist/types/codex.d.ts +4 -0
  260. package/dist/usage/usageEvents.d.ts +16 -0
  261. package/dist/usage/usageEvents.js +53 -9
  262. package/dist/watchers/jsonlTail.d.ts +26 -1
  263. package/dist/watchers/jsonlTail.js +136 -28
  264. package/dist/watchers/jsonlWatcher.js +7 -0
  265. package/dist/watchers/providerReaderWatcher.js +1 -0
  266. package/dist/watchers/types.d.ts +6 -0
  267. package/dist/workspaceRoots.d.ts +23 -0
  268. package/dist/workspaceRoots.js +93 -0
  269. package/package.json +22 -2
@@ -0,0 +1,2034 @@
1
+ /**
2
+ * Shared event aggregation engine.
3
+ *
4
+ * Pure computation class (no I/O, no framework dependencies) that processes
5
+ * SessionEvent and FollowEvent objects to accumulate session metrics.
6
+ * Both the VS Code SessionMonitor and CLI DashboardState delegate to this.
7
+ *
8
+ * @module aggregation/EventAggregator
9
+ */
10
+ import { TRUNCATION_PATTERNS } from '../parsers/jsonl';
11
+ import { PlanExtractor } from '../parsers/planExtractor';
12
+ import { toFollowEvents } from '../watchers/eventBridge';
13
+ import { formatToolSummary } from '../formatters/toolSummary';
14
+ import { isHardNoise, isHardNoiseFollowEvent, getSoftNoiseReason, classifyMessage, classifyFollowEvent, } from '../formatters/noiseClassifier';
15
+ import { classifyCostProvenance } from './costProvenance';
16
+ import { FrequencyTracker } from './FrequencyTracker';
17
+ import { HeatmapTracker } from './HeatmapTracker';
18
+ import { PatternExtractor } from './PatternExtractor';
19
+ import { categorizeError } from '../extractors/errorTaxonomy';
20
+ import { estimateTextTokens } from '../tokenEstimation';
21
+ import { calculateNormalizedUsageCost, extractNormalizedUsage, normalizeProviderUsage, } from '../usageNormalization';
22
+ // ── Defaults ──
23
+ const DEFAULT_TIMELINE_CAP = 200;
24
+ const DEFAULT_LATENCY_CAP = 100;
25
+ const DEFAULT_CONTEXT_TIMELINE_CAP = 2_000;
26
+ const DEFAULT_BURN_WINDOW_MS = 5 * 60_000;
27
+ const DEFAULT_BURN_SAMPLE_MS = 10_000;
28
+ const COMPACTION_DROP_THRESHOLD = 0.8; // >20% drop
29
+ /** Schema version for serialized snapshots. v6: token totals count each Claude response and Codex call once. */
30
+ export const SNAPSHOT_SCHEMA_VERSION = 6;
31
+ // ── Goal gate detection regex ──
32
+ const GOAL_GATE_REGEX = /\b(CRITICAL|MUST|blocker|required|must.?complete|goal.?gate|essential|do.?not.?skip|blocking)\b/i;
33
+ // ── Todo dependency parsing ──
34
+ /**
35
+ * Parses dependency references from a todo item's content text.
36
+ *
37
+ * Looks for patterns like "(blocked by Task A and Task B)" and maps
38
+ * referenced task names back to todo-{i} IDs by substring matching.
39
+ */
40
+ export function parseTodoDependencies(content, allTodos) {
41
+ const depPattern = /(?:blocked by|depends on|waiting on|requires)\s+(.+?)(?:\)|$)/i;
42
+ const match = content.match(depPattern);
43
+ if (!match)
44
+ return [];
45
+ const refs = match[1]
46
+ .split(/\s+and\s+|,\s*|&\s*/)
47
+ .map((r) => r.trim())
48
+ .filter(Boolean);
49
+ const result = [];
50
+ for (const ref of refs) {
51
+ const refLower = ref.toLowerCase();
52
+ for (let j = 0; j < allTodos.length; j++) {
53
+ const otherContent = String(allTodos[j].content || allTodos[j].subject || '').toLowerCase();
54
+ if (otherContent.includes(refLower) || refLower.includes(otherContent.split(':')[0].trim())) {
55
+ result.push(`todo-${j}`);
56
+ break;
57
+ }
58
+ }
59
+ }
60
+ return result;
61
+ }
62
+ // ── EventAggregator ──
63
+ export class EventAggregator {
64
+ // Options
65
+ timelineCap;
66
+ latencyCap;
67
+ burnWindowMs;
68
+ burnSampleMs;
69
+ computeContextSize;
70
+ providerId;
71
+ // Token totals
72
+ inputTokens = 0;
73
+ outputTokens = 0;
74
+ cacheWriteTokens = 0;
75
+ cacheReadTokens = 0;
76
+ reasoningTokens = 0;
77
+ totalTokens = 0;
78
+ /** Running cost in USD (reported + estimated). */
79
+ costUsd = 0;
80
+ reportedCostUsd = 0;
81
+ estimatedCostUsd = 0;
82
+ reportedCalls = 0;
83
+ estimatedCalls = 0;
84
+ unpricedCalls = 0;
85
+ // Per-model usage
86
+ modelUsage = new Map();
87
+ // Context size tracking
88
+ currentContextSize = 0;
89
+ previousContextSize = 0;
90
+ /**
91
+ * Index of a heuristic compaction event still awaiting its real
92
+ * post-compaction size from the next usage event. Prevents the usage-drop
93
+ * detector from double-counting an explicit compaction marker.
94
+ */
95
+ pendingHeuristicCompactionIndex = null;
96
+ // Compaction
97
+ compactionEvents = [];
98
+ // Truncation
99
+ truncationEvents = [];
100
+ // Tool analytics
101
+ toolAnalytics = new Map();
102
+ pendingToolCalls = new Map();
103
+ errorByToolCategory = new Map();
104
+ errorBuckets = new Map();
105
+ retryAttempts = 0;
106
+ finishReasons = new Map();
107
+ // Burn rate
108
+ burnSamples = [];
109
+ lastBurnSampleTime = 0;
110
+ tokensSinceLastSample = 0;
111
+ // Task state
112
+ tasks = new Map();
113
+ pendingTaskCreates = new Map();
114
+ activeTaskId = null;
115
+ // Subagent lifecycle
116
+ subagents = [];
117
+ pendingSubagents = new Map(); // toolUseId -> index
118
+ // Plan state
119
+ planExtractor;
120
+ planState = null;
121
+ planEnteredAt;
122
+ // Context attribution
123
+ contextAttribution = {
124
+ systemPrompt: 0,
125
+ userMessages: 0,
126
+ assistantResponses: 0,
127
+ toolInputs: 0,
128
+ toolOutputs: 0,
129
+ thinking: 0,
130
+ other: 0,
131
+ };
132
+ // Permission mode
133
+ currentPermissionMode = null;
134
+ permissionModeHistory = [];
135
+ // Context timeline
136
+ contextTimeline = [];
137
+ contextTimelineCap;
138
+ contextTurnIndex = 0;
139
+ // Timeline
140
+ timeline = [];
141
+ // Latency tracking
142
+ pendingUserRequest = null;
143
+ latencyRecords = [];
144
+ // Counters
145
+ messageCount = 0;
146
+ eventCount = 0;
147
+ sessionStartTime = null;
148
+ lastEventTime = null;
149
+ currentModel = null;
150
+ _providerId = null;
151
+ // Gonzo/Lazyjournal analytics
152
+ toolFrequency = new FrequencyTracker();
153
+ wordFrequency = new FrequencyTracker();
154
+ patternExtractor = new PatternExtractor();
155
+ heatmapTracker = new HeatmapTracker();
156
+ constructor(options) {
157
+ this.timelineCap = options?.timelineCap ?? DEFAULT_TIMELINE_CAP;
158
+ this.latencyCap = options?.latencyCap ?? DEFAULT_LATENCY_CAP;
159
+ this.burnWindowMs = options?.burnWindowMs ?? DEFAULT_BURN_WINDOW_MS;
160
+ this.burnSampleMs = options?.burnSampleMs ?? DEFAULT_BURN_SAMPLE_MS;
161
+ this.contextTimelineCap = options?.contextTimelineCap ?? DEFAULT_CONTEXT_TIMELINE_CAP;
162
+ this.computeContextSize = options?.computeContextSize ?? null;
163
+ this.providerId = options?.providerId ?? null;
164
+ this._providerId = this.providerId;
165
+ this.planExtractor = new PlanExtractor(options?.readPlanFile);
166
+ }
167
+ // ═══════════════════════════════════════════════════════════════════════
168
+ // processEvent — main entry for SessionEvent
169
+ // ═══════════════════════════════════════════════════════════════════════
170
+ processEvent(event) {
171
+ // 1. Increment counters, capture timestamps
172
+ this.eventCount++;
173
+ if (!this.sessionStartTime) {
174
+ this.sessionStartTime = event.timestamp;
175
+ }
176
+ this.lastEventTime = event.timestamp;
177
+ this.recordProviderMetadata(event);
178
+ // Bookkeeping rows can be useful without a message payload.
179
+ if (!event.message) {
180
+ if (event.type === 'summary') {
181
+ this.recordExplicitCompaction(event.timestamp, event.compaction);
182
+ }
183
+ this.trackPermissionMode(event);
184
+ this.addTimelineFromSessionEvent(event);
185
+ this.feedAnalytics(event.timestamp, event.type === 'summary' ? (event.summary ?? '') : '', this.extractToolNameFromEvent(event));
186
+ return;
187
+ }
188
+ // 2. Track model
189
+ if (event.message.model) {
190
+ this.currentModel = event.message.model;
191
+ }
192
+ // 3. Skip system/audit events and synthetic token-count events for messageCount
193
+ const msgId = event.message.id ?? '';
194
+ if ((event.type === 'user' || event.type === 'assistant') &&
195
+ !msgId.startsWith('token-count-')) {
196
+ this.messageCount++;
197
+ }
198
+ // 4. Latency tracking
199
+ if (event.type !== 'system') {
200
+ this.processLatency(event);
201
+ }
202
+ // 5. Token accumulation
203
+ const normalizedUsage = extractNormalizedUsage(event);
204
+ if (normalizedUsage) {
205
+ const usageCost = this.accumulateUsage(normalizedUsage, event.timestamp, event.message.model, event.message.usageKind);
206
+ this.attributePlanUsage(normalizedUsage.totalTokens, usageCost);
207
+ }
208
+ if (event.type !== 'system') {
209
+ // 6. Tool extraction from content blocks
210
+ this.extractToolsFromContent(event);
211
+ // 7. Task state
212
+ this.extractTaskStateFromEvent(event);
213
+ // 8. Subagent tracking
214
+ this.extractSubagentFromEvent(event);
215
+ // 9. Plan extraction — convert SessionEvent to a FollowEvent shape for PlanExtractor
216
+ this.extractPlanFromSessionEvent(event);
217
+ }
218
+ // 10. Context attribution
219
+ this.attributeContextFromEvent(event);
220
+ // 11. Permission mode tracking
221
+ this.trackPermissionMode(event);
222
+ if (event.type === 'summary') {
223
+ this.recordExplicitCompaction(event.timestamp, event.compaction);
224
+ }
225
+ // 12. Timeline
226
+ this.addTimelineFromSessionEvent(event);
227
+ // 13. Gonzo/Lazyjournal analytics
228
+ const summary = this.extractTextContent(event) ?? '';
229
+ const toolName = this.extractToolNameFromEvent(event);
230
+ this.feedAnalytics(event.timestamp, summary, toolName);
231
+ }
232
+ // ═══════════════════════════════════════════════════════════════════════
233
+ // processFollowEvent — adapter for CLI FollowEvent format
234
+ // ═══════════════════════════════════════════════════════════════════════
235
+ processFollowEvent(event) {
236
+ // 1. Increment counters, capture timestamps
237
+ this.eventCount++;
238
+ if (!this.sessionStartTime) {
239
+ this.sessionStartTime = event.timestamp;
240
+ }
241
+ this.lastEventTime = event.timestamp;
242
+ // Provider
243
+ if (!this._providerId && event.providerId) {
244
+ this._providerId = event.providerId;
245
+ }
246
+ // 2. Model tracking
247
+ if (event.model) {
248
+ this.currentModel = event.model;
249
+ }
250
+ // 3. Token accumulation from FollowEvent fields
251
+ if (event.tokens) {
252
+ const normalized = normalizeProviderUsage({
253
+ semantics: 'sidekick',
254
+ provider: event.providerId,
255
+ source: 'follow-event',
256
+ model: event.model,
257
+ inputTokens: event.tokens.input,
258
+ outputTokens: event.tokens.output,
259
+ cacheReadTokens: event.cacheTokens?.read,
260
+ cacheWriteTokens: event.cacheTokens?.write,
261
+ reasoningIncludedInOutput: false,
262
+ reportedCostUsd: event.cost,
263
+ });
264
+ const cost = this.accumulateUsage(normalized, event.timestamp, event.model, event.usageKind);
265
+ this.attributePlanUsage(normalized.totalTokens, cost);
266
+ }
267
+ else if (typeof event.cost === 'number' && Number.isFinite(event.cost) && event.cost >= 0) {
268
+ // Compatibility for cost-only follow events (provider-reported by definition).
269
+ this.costUsd += event.cost;
270
+ this.reportedCostUsd += event.cost;
271
+ this.reportedCalls += 1;
272
+ }
273
+ // 4. Tool tracking from FollowEvent
274
+ if (event.type === 'tool_use' && event.toolName) {
275
+ this.recordFollowToolUse(event);
276
+ this.attributePlanToolCall(event.toolName);
277
+ }
278
+ else if (event.type === 'tool_result') {
279
+ this.recordFollowToolResult(event);
280
+ }
281
+ // 5. Task state extraction
282
+ this.extractTaskStateFromFollowEvent(event);
283
+ // 6. Subagent tracking
284
+ this.extractSubagentFromFollowEvent(event);
285
+ // 7. Plan extraction
286
+ this.processPlanFollowEvent(event);
287
+ // 8. Context attribution
288
+ this.attributeContextFromFollowEvent(event);
289
+ // 9. Truncation detection from tool results
290
+ if (event.type === 'tool_result') {
291
+ this.detectTruncationFromFollowEvent(event);
292
+ }
293
+ // 10. Explicit compaction event (summary type)
294
+ if (event.type === 'summary') {
295
+ this.recordExplicitCompaction(event.timestamp, event.compaction);
296
+ }
297
+ // 11. Timeline
298
+ this.addTimelineFromFollowEvent(event);
299
+ // 12. Gonzo/Lazyjournal analytics
300
+ this.feedAnalytics(event.timestamp, event.summary, event.toolName);
301
+ // Message count (skip system events)
302
+ if (event.type === 'user' || event.type === 'assistant') {
303
+ this.messageCount++;
304
+ }
305
+ }
306
+ // ═══════════════════════════════════════════════════════════════════════
307
+ // Public getters
308
+ // ═══════════════════════════════════════════════════════════════════════
309
+ getAggregatedTokens() {
310
+ return {
311
+ inputTokens: this.inputTokens,
312
+ outputTokens: this.outputTokens,
313
+ cacheWriteTokens: this.cacheWriteTokens,
314
+ cacheReadTokens: this.cacheReadTokens,
315
+ reasoningTokens: this.reasoningTokens,
316
+ totalTokens: this.totalTokens,
317
+ costUsd: this.costUsd,
318
+ reportedCostUsd: this.reportedCostUsd,
319
+ estimatedCostUsd: this.estimatedCostUsd,
320
+ unpricedCalls: this.unpricedCalls,
321
+ costProvenance: classifyCostProvenance({
322
+ reportedCalls: this.reportedCalls,
323
+ estimatedCalls: this.estimatedCalls,
324
+ unpricedCalls: this.unpricedCalls,
325
+ }),
326
+ reportedCost: this.costUsd,
327
+ };
328
+ }
329
+ getModelStats() {
330
+ return Array.from(this.modelUsage.entries())
331
+ .map(([model, acc]) => ({ model, ...acc }))
332
+ .sort((a, b) => b.calls - a.calls);
333
+ }
334
+ getToolStats() {
335
+ return Array.from(this.toolAnalytics.values()).sort((a, b) => b.successCount +
336
+ b.failureCount +
337
+ b.pendingCount -
338
+ (a.successCount + a.failureCount + a.pendingCount));
339
+ }
340
+ getErrorRollup() {
341
+ return {
342
+ totalFailures: Array.from(this.errorByToolCategory.values()).reduce((sum, entry) => sum + entry.count, 0),
343
+ byToolCategory: Array.from(this.errorByToolCategory.values()).sort((a, b) => b.count - a.count),
344
+ byHourModel: Array.from(this.errorBuckets.values()).sort((a, b) => a.hour.localeCompare(b.hour)),
345
+ retryAttempts: this.retryAttempts,
346
+ finishReasons: Array.from(this.finishReasons, ([reason, count]) => ({ reason, count })).sort((a, b) => b.count - a.count),
347
+ };
348
+ }
349
+ getCompactionEvents() {
350
+ return [...this.compactionEvents];
351
+ }
352
+ getTruncationEvents() {
353
+ return [...this.truncationEvents];
354
+ }
355
+ getBurnRate() {
356
+ const points = this.burnSamples.map((s) => s.tokens);
357
+ const tokensPerMinute = points.length > 0 ? points[points.length - 1] : 0;
358
+ return {
359
+ tokensPerMinute,
360
+ points: [...points],
361
+ sampleCount: this.burnSamples.length,
362
+ };
363
+ }
364
+ getTaskState() {
365
+ return {
366
+ tasks: new Map(this.tasks),
367
+ activeTaskId: this.activeTaskId,
368
+ };
369
+ }
370
+ getSubagents() {
371
+ return [...this.subagents];
372
+ }
373
+ getPlan() {
374
+ return this.planState
375
+ ? { ...this.planState, steps: this.planState.steps.map((step) => ({ ...step })) }
376
+ : null;
377
+ }
378
+ getContextAttribution() {
379
+ return { ...this.contextAttribution };
380
+ }
381
+ getTimeline() {
382
+ return [...this.timeline];
383
+ }
384
+ getLatencyStats() {
385
+ if (this.latencyRecords.length === 0)
386
+ return null;
387
+ const records = this.latencyRecords;
388
+ const totalFirstToken = records.reduce((sum, r) => sum + r.firstTokenLatencyMs, 0);
389
+ const totalResponse = records.reduce((sum, r) => sum + r.totalResponseTimeMs, 0);
390
+ const maxFirstToken = Math.max(...records.map((r) => r.firstTokenLatencyMs));
391
+ const last = records[records.length - 1];
392
+ return {
393
+ recentLatencies: [...records],
394
+ avgFirstTokenLatencyMs: Math.round(totalFirstToken / records.length),
395
+ maxFirstTokenLatencyMs: maxFirstToken,
396
+ avgTotalResponseTimeMs: Math.round(totalResponse / records.length),
397
+ lastFirstTokenLatencyMs: last ? last.firstTokenLatencyMs : null,
398
+ completedCycles: records.length,
399
+ };
400
+ }
401
+ getMetrics() {
402
+ return {
403
+ sessionStartTime: this.sessionStartTime,
404
+ lastEventTime: this.lastEventTime,
405
+ messageCount: this.messageCount,
406
+ eventCount: this.eventCount,
407
+ currentModel: this.currentModel,
408
+ providerId: this._providerId,
409
+ tokens: this.getAggregatedTokens(),
410
+ modelStats: this.getModelStats(),
411
+ currentContextSize: this.currentContextSize,
412
+ contextAttribution: this.getContextAttribution(),
413
+ compactionCount: this.compactionEvents.length,
414
+ compactionEvents: this.getCompactionEvents(),
415
+ truncationCount: this.truncationEvents.length,
416
+ truncationEvents: this.getTruncationEvents(),
417
+ toolStats: this.getToolStats(),
418
+ errorRollup: this.getErrorRollup(),
419
+ burnRate: this.getBurnRate(),
420
+ taskState: this.getTaskState(),
421
+ subagents: this.getSubagents(),
422
+ plan: this.getPlan(),
423
+ permissionMode: this.currentPermissionMode,
424
+ permissionModeHistory: [...this.permissionModeHistory],
425
+ contextTimeline: [...this.contextTimeline],
426
+ timeline: this.getTimeline(),
427
+ latencyStats: this.getLatencyStats(),
428
+ toolFrequency: this.toolFrequency.getTopN(20).map((e) => ({ name: e.key, count: e.count })),
429
+ wordFrequency: this.wordFrequency.getTopN(20).map((e) => ({ name: e.key, count: e.count })),
430
+ patterns: this.patternExtractor.getPatterns().slice(0, 20),
431
+ heatmapBuckets: this.heatmapTracker.getBuckets(),
432
+ };
433
+ }
434
+ // ═══════════════════════════════════════════════════════════════════════
435
+ // reset — clear all state
436
+ // ═══════════════════════════════════════════════════════════════════════
437
+ reset() {
438
+ this.inputTokens = 0;
439
+ this.outputTokens = 0;
440
+ this.cacheWriteTokens = 0;
441
+ this.cacheReadTokens = 0;
442
+ this.reasoningTokens = 0;
443
+ this.totalTokens = 0;
444
+ this.costUsd = 0;
445
+ this.reportedCostUsd = 0;
446
+ this.estimatedCostUsd = 0;
447
+ this.reportedCalls = 0;
448
+ this.estimatedCalls = 0;
449
+ this.unpricedCalls = 0;
450
+ this.modelUsage.clear();
451
+ this.currentContextSize = 0;
452
+ this.previousContextSize = 0;
453
+ this.compactionEvents = [];
454
+ this.truncationEvents = [];
455
+ this.toolAnalytics.clear();
456
+ this.pendingToolCalls.clear();
457
+ this.errorByToolCategory.clear();
458
+ this.errorBuckets.clear();
459
+ this.retryAttempts = 0;
460
+ this.finishReasons.clear();
461
+ this.burnSamples = [];
462
+ this.lastBurnSampleTime = 0;
463
+ this.tokensSinceLastSample = 0;
464
+ this.tasks.clear();
465
+ this.pendingTaskCreates.clear();
466
+ this.activeTaskId = null;
467
+ this.subagents = [];
468
+ this.pendingSubagents.clear();
469
+ this.planExtractor.reset();
470
+ this.planState = null;
471
+ this.planEnteredAt = undefined;
472
+ this.currentPermissionMode = null;
473
+ this.permissionModeHistory = [];
474
+ this.contextTimeline = [];
475
+ this.contextTurnIndex = 0;
476
+ this.contextAttribution = {
477
+ systemPrompt: 0,
478
+ userMessages: 0,
479
+ assistantResponses: 0,
480
+ toolInputs: 0,
481
+ toolOutputs: 0,
482
+ thinking: 0,
483
+ other: 0,
484
+ };
485
+ this.timeline = [];
486
+ this.pendingUserRequest = null;
487
+ this.latencyRecords = [];
488
+ this.toolFrequency.reset();
489
+ this.wordFrequency.reset();
490
+ this.patternExtractor.reset();
491
+ this.heatmapTracker.reset();
492
+ this.messageCount = 0;
493
+ this.eventCount = 0;
494
+ this.sessionStartTime = null;
495
+ this.lastEventTime = null;
496
+ this.currentModel = null;
497
+ this._providerId = this.providerId;
498
+ }
499
+ // ═══════════════════════════════════════════════════════════════════════
500
+ // Seed methods — for initializing state from provider snapshots
501
+ // ═══════════════════════════════════════════════════════════════════════
502
+ /** Seeds the current context size (e.g., from a provider usage snapshot on attach). */
503
+ seedContextSize(size) {
504
+ this.currentContextSize = size;
505
+ this.previousContextSize = size;
506
+ }
507
+ /** Seeds context attribution (e.g., from a provider's DB-backed attribution). */
508
+ seedContextAttribution(attribution) {
509
+ this.contextAttribution = { ...attribution };
510
+ }
511
+ // ═══════════════════════════════════════════════════════════════════════
512
+ // Snapshot serialization — for fast session resume
513
+ // ═══════════════════════════════════════════════════════════════════════
514
+ /** Serializes all mutable state to a JSON-safe object for snapshot persistence. */
515
+ serialize() {
516
+ return {
517
+ version: SNAPSHOT_SCHEMA_VERSION,
518
+ tokens: {
519
+ input: this.inputTokens,
520
+ output: this.outputTokens,
521
+ cacheWrite: this.cacheWriteTokens,
522
+ cacheRead: this.cacheReadTokens,
523
+ reasoning: this.reasoningTokens,
524
+ total: this.totalTokens,
525
+ reportedCost: this.costUsd,
526
+ costUsd: this.costUsd,
527
+ reportedCostUsd: this.reportedCostUsd,
528
+ estimatedCostUsd: this.estimatedCostUsd,
529
+ reportedCalls: this.reportedCalls,
530
+ estimatedCalls: this.estimatedCalls,
531
+ unpricedCalls: this.unpricedCalls,
532
+ },
533
+ modelUsage: Array.from(this.modelUsage.entries()),
534
+ contextSize: this.currentContextSize,
535
+ previousContextSize: this.previousContextSize,
536
+ compactionEvents: this.compactionEvents,
537
+ truncationEvents: this.truncationEvents,
538
+ toolAnalytics: Array.from(this.toolAnalytics.entries()),
539
+ errorByToolCategory: Array.from(this.errorByToolCategory.entries()),
540
+ errorBuckets: Array.from(this.errorBuckets.entries()),
541
+ retryAttempts: this.retryAttempts,
542
+ finishReasons: Array.from(this.finishReasons.entries()),
543
+ contextAttribution: { ...this.contextAttribution },
544
+ burnSamples: [...this.burnSamples],
545
+ lastBurnSampleTime: this.lastBurnSampleTime,
546
+ tokensSinceLastSample: this.tokensSinceLastSample,
547
+ latencyRecords: [...this.latencyRecords],
548
+ tasks: Array.from(this.tasks.entries()),
549
+ activeTaskId: this.activeTaskId,
550
+ subagents: [...this.subagents],
551
+ permissionMode: this.currentPermissionMode,
552
+ permissionModeHistory: [...this.permissionModeHistory],
553
+ contextTimeline: [...this.contextTimeline],
554
+ contextTurnIndex: this.contextTurnIndex,
555
+ timeline: [...this.timeline],
556
+ messageCount: this.messageCount,
557
+ eventCount: this.eventCount,
558
+ sessionStartTime: this.sessionStartTime,
559
+ lastEventTime: this.lastEventTime,
560
+ currentModel: this.currentModel,
561
+ toolFrequency: this.toolFrequency.serialize(),
562
+ wordFrequency: this.wordFrequency.serialize(),
563
+ patternState: this.patternExtractor.serialize(),
564
+ heatmapState: this.heatmapTracker.serialize(),
565
+ };
566
+ }
567
+ /** Restores mutable state from a serialized snapshot. Clears transient state (pending calls). */
568
+ restore(state) {
569
+ if (state.version !== SNAPSHOT_SCHEMA_VERSION) {
570
+ return false;
571
+ }
572
+ this.inputTokens = state.tokens.input;
573
+ this.outputTokens = state.tokens.output;
574
+ this.cacheWriteTokens = state.tokens.cacheWrite;
575
+ this.cacheReadTokens = state.tokens.cacheRead;
576
+ this.reasoningTokens = state.tokens.reasoning;
577
+ this.totalTokens = state.tokens.total;
578
+ this.costUsd = state.tokens.costUsd;
579
+ this.reportedCostUsd = state.tokens.reportedCostUsd;
580
+ this.estimatedCostUsd = state.tokens.estimatedCostUsd;
581
+ this.reportedCalls = state.tokens.reportedCalls;
582
+ this.estimatedCalls = state.tokens.estimatedCalls;
583
+ this.unpricedCalls = state.tokens.unpricedCalls;
584
+ this.modelUsage = new Map(state.modelUsage);
585
+ this.currentContextSize = state.contextSize;
586
+ this.previousContextSize = state.previousContextSize;
587
+ this.compactionEvents = [...state.compactionEvents];
588
+ this.truncationEvents = [...state.truncationEvents];
589
+ this.toolAnalytics = new Map(state.toolAnalytics);
590
+ this.errorByToolCategory = new Map(state.errorByToolCategory);
591
+ this.errorBuckets = new Map(state.errorBuckets);
592
+ this.retryAttempts = state.retryAttempts;
593
+ this.finishReasons = new Map(state.finishReasons);
594
+ this.contextAttribution = { ...state.contextAttribution };
595
+ this.burnSamples = [...state.burnSamples];
596
+ this.lastBurnSampleTime = state.lastBurnSampleTime;
597
+ this.tokensSinceLastSample = state.tokensSinceLastSample;
598
+ this.latencyRecords = [...state.latencyRecords];
599
+ this.tasks = new Map(state.tasks);
600
+ this.activeTaskId = state.activeTaskId;
601
+ this.subagents = [...state.subagents];
602
+ this.currentPermissionMode = state.permissionMode ?? null;
603
+ this.permissionModeHistory = state.permissionModeHistory
604
+ ? [...state.permissionModeHistory]
605
+ : [];
606
+ this.contextTimeline = state.contextTimeline
607
+ ? state.contextTimeline.slice(-this.contextTimelineCap)
608
+ : [];
609
+ this.contextTurnIndex = state.contextTurnIndex ?? 0;
610
+ this.timeline = [...state.timeline];
611
+ this.messageCount = state.messageCount;
612
+ this.eventCount = state.eventCount;
613
+ this.sessionStartTime = state.sessionStartTime;
614
+ this.lastEventTime = state.lastEventTime;
615
+ this.currentModel = state.currentModel;
616
+ // Gonzo/Lazyjournal analytics restore
617
+ if (state.toolFrequency)
618
+ this.toolFrequency.restore(state.toolFrequency);
619
+ if (state.wordFrequency)
620
+ this.wordFrequency.restore(state.wordFrequency);
621
+ if (state.patternState)
622
+ this.patternExtractor.restore(state.patternState);
623
+ if (state.heatmapState)
624
+ this.heatmapTracker.restore(state.heatmapState);
625
+ // Clear transient state — pending calls won't survive a snapshot boundary
626
+ this.pendingToolCalls.clear();
627
+ this.pendingTaskCreates.clear();
628
+ this.pendingSubagents.clear();
629
+ this.pendingUserRequest = null;
630
+ this.planExtractor.reset();
631
+ return true;
632
+ }
633
+ // ═══════════════════════════════════════════════════════════════════════
634
+ // Private: Token & Context
635
+ // ═══════════════════════════════════════════════════════════════════════
636
+ /**
637
+ * Add one usage record to the running totals. A `'correction'` tops up a call
638
+ * already counted (a later split line of the same response grew), so it adds
639
+ * tokens and cost but no call, context sample, or compaction check.
640
+ */
641
+ accumulateUsage(usage, timestamp, model, kind = 'call') {
642
+ const isCall = kind !== 'correction';
643
+ const inputTok = usage.uncachedInputTokens;
644
+ const outputTok = usage.outputTokens;
645
+ const cacheWrite = usage.cacheWriteTokens;
646
+ const cacheRead = usage.cacheReadTokens;
647
+ const reasoningTok = usage.reasoningTokens;
648
+ const modelKey = model ?? this.currentModel;
649
+ const pricedUsage = calculateNormalizedUsageCost({ usage, modelId: modelKey ?? undefined });
650
+ const priced = pricedUsage.source !== 'unpriced';
651
+ const cost = pricedUsage.costUsd ?? 0;
652
+ this.inputTokens += inputTok;
653
+ this.outputTokens += outputTok;
654
+ this.cacheWriteTokens += cacheWrite;
655
+ this.cacheReadTokens += cacheRead;
656
+ this.reasoningTokens += reasoningTok;
657
+ this.totalTokens += usage.totalTokens;
658
+ this.costUsd += cost;
659
+ const callIncrement = isCall ? 1 : 0;
660
+ if (pricedUsage.source === 'provider-reported') {
661
+ this.reportedCostUsd += cost;
662
+ this.reportedCalls += callIncrement;
663
+ }
664
+ else if (pricedUsage.source === 'unpriced') {
665
+ this.unpricedCalls += callIncrement;
666
+ }
667
+ else {
668
+ this.estimatedCostUsd += cost;
669
+ this.estimatedCalls += callIncrement;
670
+ }
671
+ // Burn rate sample accumulation
672
+ this.tokensSinceLastSample += usage.totalTokens;
673
+ if (!isCall) {
674
+ this.accumulateModelUsage(usage, model, cost, priced, false);
675
+ this.updateBurnRate(timestamp);
676
+ return cost;
677
+ }
678
+ // Context size computation
679
+ let contextSize;
680
+ if (this.computeContextSize) {
681
+ contextSize = this.computeContextSize({
682
+ inputTokens: inputTok,
683
+ outputTokens: outputTok,
684
+ cacheWriteTokens: cacheWrite,
685
+ cacheReadTokens: cacheRead,
686
+ reasoningTokens: reasoningTok,
687
+ });
688
+ }
689
+ else {
690
+ contextSize = inputTok + cacheWrite + cacheRead;
691
+ }
692
+ // Compaction detection
693
+ if (this.resolvePendingHeuristicCompaction(contextSize)) {
694
+ // The drop belongs to the explicit compaction marker just recorded.
695
+ }
696
+ else if (this.previousContextSize > 0 &&
697
+ contextSize < this.previousContextSize * COMPACTION_DROP_THRESHOLD) {
698
+ this.compactionEvents.push({
699
+ timestamp: new Date(timestamp),
700
+ contextBefore: this.previousContextSize,
701
+ contextAfter: contextSize,
702
+ tokensReclaimed: this.previousContextSize - contextSize,
703
+ source: 'heuristic',
704
+ });
705
+ }
706
+ this.previousContextSize = contextSize;
707
+ this.currentContextSize = contextSize;
708
+ // Context timeline tracking
709
+ this.contextTimeline.push({
710
+ timestamp,
711
+ inputTokens: contextSize,
712
+ turnIndex: this.contextTurnIndex++,
713
+ });
714
+ if (this.contextTimeline.length > this.contextTimelineCap) {
715
+ this.contextTimeline.splice(0, this.contextTimeline.length - this.contextTimelineCap);
716
+ }
717
+ this.accumulateModelUsage(usage, model, cost, priced, true);
718
+ // Burn rate sampling
719
+ this.updateBurnRate(timestamp);
720
+ return cost;
721
+ }
722
+ /**
723
+ * Per-model usage. Accumulate reasoning tokens and an inverted-sticky
724
+ * `priced` flag so one unpriced event taints the aggregate (UI renders "—").
725
+ */
726
+ accumulateModelUsage(usage, model, cost, priced, countCall) {
727
+ const inputTok = usage.uncachedInputTokens;
728
+ const outputTok = usage.outputTokens;
729
+ const cacheWrite = usage.cacheWriteTokens;
730
+ const cacheRead = usage.cacheReadTokens;
731
+ const reasoningTok = usage.reasoningTokens;
732
+ const perModelKey = model ?? this.currentModel ?? 'unknown';
733
+ const acc = this.modelUsage.get(perModelKey) ?? {
734
+ calls: 0,
735
+ tokens: 0,
736
+ inputTokens: 0,
737
+ outputTokens: 0,
738
+ cacheWriteTokens: 0,
739
+ cacheReadTokens: 0,
740
+ reasoningTokens: 0,
741
+ cost: 0,
742
+ priced: true,
743
+ };
744
+ if (countCall)
745
+ acc.calls++;
746
+ acc.tokens += usage.totalTokens;
747
+ acc.inputTokens += inputTok;
748
+ acc.outputTokens += outputTok;
749
+ acc.cacheWriteTokens += cacheWrite;
750
+ acc.cacheReadTokens += cacheRead;
751
+ acc.reasoningTokens = (acc.reasoningTokens ?? 0) + reasoningTok;
752
+ acc.cost += cost;
753
+ if (!priced)
754
+ acc.priced = false;
755
+ this.modelUsage.set(perModelKey, acc);
756
+ }
757
+ // ═══════════════════════════════════════════════════════════════════════
758
+ // Private: Burn Rate
759
+ // ═══════════════════════════════════════════════════════════════════════
760
+ updateBurnRate(timestamp) {
761
+ const now = new Date(timestamp).getTime();
762
+ if (isNaN(now))
763
+ return;
764
+ if (this.lastBurnSampleTime === 0) {
765
+ this.lastBurnSampleTime = now;
766
+ return;
767
+ }
768
+ const elapsed = now - this.lastBurnSampleTime;
769
+ if (elapsed >= this.burnSampleMs) {
770
+ const tokPerMin = elapsed > 0 ? Math.round((this.tokensSinceLastSample / elapsed) * 60_000) : 0;
771
+ this.burnSamples.push({ time: now, tokens: tokPerMin });
772
+ this.tokensSinceLastSample = 0;
773
+ this.lastBurnSampleTime = now;
774
+ // Trim to window
775
+ const cutoff = now - this.burnWindowMs;
776
+ this.burnSamples = this.burnSamples.filter((s) => s.time >= cutoff);
777
+ }
778
+ }
779
+ // ═══════════════════════════════════════════════════════════════════════
780
+ // Private: Latency Tracking (SessionEvent only)
781
+ // ═══════════════════════════════════════════════════════════════════════
782
+ processLatency(event) {
783
+ const now = new Date(event.timestamp);
784
+ if (event.type === 'user' && this.hasTextContent(event)) {
785
+ // User event with text content -> start tracking
786
+ this.pendingUserRequest = {
787
+ timestamp: now,
788
+ firstResponseReceived: false,
789
+ };
790
+ }
791
+ else if (event.type === 'assistant' && this.pendingUserRequest) {
792
+ if (!this.pendingUserRequest.firstResponseReceived && this.hasTextContent(event)) {
793
+ // First assistant response with text
794
+ this.pendingUserRequest.firstResponseReceived = true;
795
+ this.pendingUserRequest.firstResponseTimestamp = now;
796
+ this.pendingUserRequest.firstTokenLatencyMs =
797
+ now.getTime() - this.pendingUserRequest.timestamp.getTime();
798
+ }
799
+ if (event.message.usage && this.pendingUserRequest.firstResponseReceived) {
800
+ // Assistant with usage -> complete the cycle
801
+ const totalResponseTimeMs = now.getTime() - this.pendingUserRequest.timestamp.getTime();
802
+ const firstTokenLatencyMs = this.pendingUserRequest.firstTokenLatencyMs ?? totalResponseTimeMs;
803
+ this.latencyRecords.push({
804
+ firstTokenLatencyMs,
805
+ totalResponseTimeMs,
806
+ requestTimestamp: this.pendingUserRequest.timestamp,
807
+ });
808
+ // Cap latency records
809
+ if (this.latencyRecords.length > this.latencyCap) {
810
+ this.latencyRecords.shift();
811
+ }
812
+ this.pendingUserRequest = null;
813
+ }
814
+ }
815
+ }
816
+ // ═══════════════════════════════════════════════════════════════════════
817
+ // Private: Tool Extraction from SessionEvent content blocks
818
+ // ═══════════════════════════════════════════════════════════════════════
819
+ extractToolsFromContent(event) {
820
+ const content = event.message?.content;
821
+ if (!Array.isArray(content))
822
+ return;
823
+ for (const block of content) {
824
+ if (block.type === 'tool_use') {
825
+ // Assistant content: tool_use block
826
+ const toolUseId = block.id;
827
+ const name = block.name;
828
+ if (!toolUseId || !name)
829
+ continue;
830
+ // Record in toolAnalytics
831
+ const analytics = this.toolAnalytics.get(name) ?? {
832
+ name,
833
+ successCount: 0,
834
+ failureCount: 0,
835
+ totalDuration: 0,
836
+ completedCount: 0,
837
+ pendingCount: 0,
838
+ };
839
+ analytics.pendingCount++;
840
+ this.toolAnalytics.set(name, analytics);
841
+ // Track pending
842
+ this.pendingToolCalls.set(toolUseId, {
843
+ toolUseId,
844
+ name,
845
+ startTime: new Date(event.timestamp),
846
+ });
847
+ this.attributePlanToolCall(name);
848
+ }
849
+ else if (block.type === 'tool_result') {
850
+ // User content: tool_result block
851
+ const toolUseId = block.tool_use_id;
852
+ const isError = block.is_error === true;
853
+ if (!toolUseId)
854
+ continue;
855
+ const pending = this.pendingToolCalls.get(toolUseId);
856
+ if (pending) {
857
+ if (isError) {
858
+ this.recordError(pending.name, categorizeError(block.content), event.timestamp, this.currentModel ?? 'unknown');
859
+ }
860
+ this.pendingToolCalls.delete(toolUseId);
861
+ const analytics = this.toolAnalytics.get(pending.name);
862
+ if (analytics) {
863
+ analytics.pendingCount = Math.max(0, analytics.pendingCount - 1);
864
+ analytics.completedCount++;
865
+ if (isError) {
866
+ analytics.failureCount++;
867
+ }
868
+ else {
869
+ analytics.successCount++;
870
+ }
871
+ // Duration
872
+ const duration = new Date(event.timestamp).getTime() - pending.startTime.getTime();
873
+ if (duration >= 0) {
874
+ analytics.totalDuration += duration;
875
+ }
876
+ }
877
+ }
878
+ // Truncation detection
879
+ this.detectTruncationInContent(block, event.timestamp);
880
+ }
881
+ }
882
+ }
883
+ detectTruncationInContent(block, timestamp) {
884
+ const content = block.content;
885
+ let text;
886
+ if (typeof content === 'string')
887
+ text = content;
888
+ else if (content)
889
+ text = JSON.stringify(content);
890
+ else
891
+ text = '';
892
+ if (!text)
893
+ return;
894
+ for (const pattern of TRUNCATION_PATTERNS) {
895
+ if (pattern.regex.test(text)) {
896
+ // Try to find the tool name from pending calls
897
+ const toolUseId = block.tool_use_id;
898
+ const pending = toolUseId ? this.pendingToolCalls.get(toolUseId) : undefined;
899
+ this.truncationEvents.push({
900
+ timestamp: new Date(timestamp),
901
+ toolName: pending?.name ?? 'unknown',
902
+ marker: pattern.name,
903
+ });
904
+ break;
905
+ }
906
+ }
907
+ }
908
+ // ═══════════════════════════════════════════════════════════════════════
909
+ // Private: Tool tracking from FollowEvent
910
+ // ═══════════════════════════════════════════════════════════════════════
911
+ recordFollowToolUse(event) {
912
+ const name = event.toolName;
913
+ // Update analytics
914
+ const analytics = this.toolAnalytics.get(name) ?? {
915
+ name,
916
+ successCount: 0,
917
+ failureCount: 0,
918
+ totalDuration: 0,
919
+ completedCount: 0,
920
+ pendingCount: 0,
921
+ };
922
+ analytics.pendingCount++;
923
+ this.toolAnalytics.set(name, analytics);
924
+ // Track pending by tool_use_id from raw
925
+ const raw = event.raw;
926
+ const toolUseId = raw?.id;
927
+ if (toolUseId) {
928
+ this.pendingToolCalls.set(toolUseId, {
929
+ toolUseId,
930
+ name,
931
+ startTime: new Date(event.timestamp),
932
+ });
933
+ }
934
+ }
935
+ recordFollowToolResult(event) {
936
+ const raw = event.raw;
937
+ if (!raw)
938
+ return;
939
+ const toolUseId = raw.tool_use_id;
940
+ if (!toolUseId)
941
+ return;
942
+ const pending = this.pendingToolCalls.get(toolUseId);
943
+ if (!pending)
944
+ return;
945
+ this.pendingToolCalls.delete(toolUseId);
946
+ const analytics = this.toolAnalytics.get(pending.name);
947
+ if (analytics) {
948
+ analytics.pendingCount = Math.max(0, analytics.pendingCount - 1);
949
+ analytics.completedCount++;
950
+ const isError = raw.is_error === true;
951
+ if (isError) {
952
+ analytics.failureCount++;
953
+ this.recordError(pending.name, categorizeError(raw.content ?? event.summary), event.timestamp, event.model ?? this.currentModel ?? 'unknown');
954
+ }
955
+ else {
956
+ analytics.successCount++;
957
+ }
958
+ const duration = new Date(event.timestamp).getTime() - pending.startTime.getTime();
959
+ if (duration >= 0) {
960
+ analytics.totalDuration += duration;
961
+ }
962
+ }
963
+ }
964
+ recordProviderMetadata(event) {
965
+ const metadata = event.providerMetadata;
966
+ if (!metadata)
967
+ return;
968
+ this.retryAttempts += metadata.retryAttempts?.length ?? 0;
969
+ for (const reason of metadata.finishReasons ?? []) {
970
+ this.finishReasons.set(reason, (this.finishReasons.get(reason) ?? 0) + 1);
971
+ }
972
+ if (metadata.providerError) {
973
+ this.recordError('ProviderAPI', categorizeError(metadata.providerError, metadata.providerError.type), event.timestamp, event.message?.model ?? this.currentModel ?? 'unknown');
974
+ }
975
+ }
976
+ recordError(tool, category, timestamp, model) {
977
+ const categoryKey = `${tool}\u0000${category}`;
978
+ const current = this.errorByToolCategory.get(categoryKey);
979
+ if (current)
980
+ current.count++;
981
+ else
982
+ this.errorByToolCategory.set(categoryKey, { tool, category, count: 1 });
983
+ const parsed = new Date(timestamp);
984
+ const hour = Number.isNaN(parsed.getTime())
985
+ ? timestamp.slice(0, 13)
986
+ : parsed.toISOString().slice(0, 13);
987
+ const bucketKey = `${hour}\u0000${model}\u0000${tool}\u0000${category}`;
988
+ const bucket = this.errorBuckets.get(bucketKey);
989
+ if (bucket)
990
+ bucket.count++;
991
+ else
992
+ this.errorBuckets.set(bucketKey, { hour, model, tool, category, count: 1 });
993
+ }
994
+ detectTruncationFromFollowEvent(event) {
995
+ const raw = event.raw;
996
+ if (!raw)
997
+ return;
998
+ const content = raw.content;
999
+ let text;
1000
+ if (typeof content === 'string')
1001
+ text = content;
1002
+ else if (content)
1003
+ text = JSON.stringify(content);
1004
+ else
1005
+ text = '';
1006
+ if (!text)
1007
+ return;
1008
+ for (const pattern of TRUNCATION_PATTERNS) {
1009
+ if (pattern.regex.test(text)) {
1010
+ const toolUseId = raw.tool_use_id;
1011
+ // Try to find the tool name — it may have been resolved already, so check summary
1012
+ let toolName = 'unknown';
1013
+ if (toolUseId) {
1014
+ const pending = this.pendingToolCalls.get(toolUseId);
1015
+ if (pending)
1016
+ toolName = pending.name;
1017
+ }
1018
+ if (toolName === 'unknown' && event.toolName) {
1019
+ toolName = event.toolName;
1020
+ }
1021
+ this.truncationEvents.push({
1022
+ timestamp: new Date(event.timestamp),
1023
+ toolName,
1024
+ marker: pattern.name,
1025
+ });
1026
+ break;
1027
+ }
1028
+ }
1029
+ }
1030
+ recordExplicitCompaction(timestamp, reported) {
1031
+ const hasReported = reported !== undefined &&
1032
+ Number.isFinite(reported.tokensBefore) &&
1033
+ Number.isFinite(reported.tokensAfter) &&
1034
+ reported.tokensBefore >= reported.tokensAfter &&
1035
+ reported.tokensAfter >= 0;
1036
+ // Without reported counts, assume the full context was reclaimed until
1037
+ // the next usage event supplies the real post-compaction size.
1038
+ const contextBefore = hasReported ? reported.tokensBefore : this.previousContextSize;
1039
+ const contextAfter = hasReported ? reported.tokensAfter : 0;
1040
+ this.compactionEvents.push({
1041
+ timestamp: new Date(timestamp),
1042
+ contextBefore,
1043
+ contextAfter,
1044
+ tokensReclaimed: Math.max(0, contextBefore - contextAfter),
1045
+ source: hasReported ? 'reported' : 'heuristic',
1046
+ });
1047
+ if (hasReported) {
1048
+ this.previousContextSize = contextAfter;
1049
+ this.currentContextSize = contextAfter;
1050
+ }
1051
+ else {
1052
+ this.pendingHeuristicCompactionIndex = this.compactionEvents.length - 1;
1053
+ }
1054
+ }
1055
+ /**
1056
+ * Fills a pending heuristic compaction with the first observed
1057
+ * post-compaction context size. Returns true when the observed drop was
1058
+ * consumed by that pending event, so the caller must not record it again.
1059
+ */
1060
+ resolvePendingHeuristicCompaction(contextSize) {
1061
+ if (this.pendingHeuristicCompactionIndex === null)
1062
+ return false;
1063
+ const compaction = this.compactionEvents[this.pendingHeuristicCompactionIndex];
1064
+ this.pendingHeuristicCompactionIndex = null;
1065
+ if (!compaction)
1066
+ return false;
1067
+ if (contextSize < compaction.contextBefore) {
1068
+ compaction.contextAfter = contextSize;
1069
+ compaction.tokensReclaimed = compaction.contextBefore - contextSize;
1070
+ }
1071
+ return true;
1072
+ }
1073
+ // ═══════════════════════════════════════════════════════════════════════
1074
+ // Private: Task State from SessionEvent
1075
+ // ═══════════════════════════════════════════════════════════════════════
1076
+ extractTaskStateFromEvent(event) {
1077
+ const content = event.message?.content;
1078
+ if (!Array.isArray(content))
1079
+ return;
1080
+ for (const block of content) {
1081
+ if (block.type === 'tool_use') {
1082
+ const name = block.name;
1083
+ const input = block.input;
1084
+ const toolUseId = block.id;
1085
+ if (!input)
1086
+ continue;
1087
+ if (name === 'TaskCreate' && toolUseId) {
1088
+ this.pendingTaskCreates.set(toolUseId, {
1089
+ subject: input.subject || 'Untitled',
1090
+ description: input.description,
1091
+ activeForm: input.activeForm,
1092
+ subagentType: input.subagentType,
1093
+ isGoalGate: input.isGoalGate,
1094
+ timestamp: new Date(event.timestamp),
1095
+ });
1096
+ }
1097
+ else if (name === 'TaskUpdate') {
1098
+ this.applyTaskUpdate(input);
1099
+ this.syncPlanStepFromTaskUpdate(input, new Date(event.timestamp));
1100
+ }
1101
+ else if (name === 'TodoWrite') {
1102
+ this.handleTodoWrite(input, new Date(event.timestamp));
1103
+ }
1104
+ else if (name === 'UpdatePlan') {
1105
+ this.handleUpdatePlan(input, new Date(event.timestamp));
1106
+ }
1107
+ else if ((name === 'Agent' || name === 'Task') && toolUseId) {
1108
+ this.handleAgentSpawn(toolUseId, input, new Date(event.timestamp));
1109
+ }
1110
+ }
1111
+ else if (block.type === 'tool_result') {
1112
+ const toolUseId = block.tool_use_id;
1113
+ if (!toolUseId)
1114
+ continue;
1115
+ const isError = block.is_error ?? false;
1116
+ this.resolveTaskCreate(toolUseId, block.content ?? block.output, event.timestamp, isError);
1117
+ this.resolveAgentResult(toolUseId, isError, event.timestamp);
1118
+ }
1119
+ }
1120
+ }
1121
+ // ═══════════════════════════════════════════════════════════════════════
1122
+ // Private: Task State from FollowEvent
1123
+ // ═══════════════════════════════════════════════════════════════════════
1124
+ extractTaskStateFromFollowEvent(event) {
1125
+ const raw = event.raw;
1126
+ if (event.type === 'tool_use' && event.toolName) {
1127
+ if (!raw?.input)
1128
+ return;
1129
+ const input = raw.input;
1130
+ const toolUseId = raw.id;
1131
+ if (event.toolName === 'TaskCreate' && toolUseId) {
1132
+ this.pendingTaskCreates.set(toolUseId, {
1133
+ subject: input.subject || 'Untitled',
1134
+ description: input.description,
1135
+ activeForm: input.activeForm,
1136
+ subagentType: input.subagentType,
1137
+ isGoalGate: input.isGoalGate,
1138
+ timestamp: new Date(event.timestamp),
1139
+ });
1140
+ }
1141
+ else if (event.toolName === 'TaskUpdate') {
1142
+ this.applyTaskUpdate(input);
1143
+ this.syncPlanStepFromTaskUpdate(input, new Date(event.timestamp));
1144
+ }
1145
+ else if (event.toolName === 'TodoWrite') {
1146
+ this.handleTodoWrite(input, new Date(event.timestamp));
1147
+ }
1148
+ else if (event.toolName === 'UpdatePlan') {
1149
+ this.handleUpdatePlan(input, new Date(event.timestamp));
1150
+ }
1151
+ else if ((event.toolName === 'Agent' || event.toolName === 'Task') && toolUseId) {
1152
+ this.handleAgentSpawn(toolUseId, input, new Date(event.timestamp));
1153
+ }
1154
+ }
1155
+ else if (event.type === 'tool_result') {
1156
+ if (!raw)
1157
+ return;
1158
+ const toolUseId = raw.tool_use_id;
1159
+ if (!toolUseId)
1160
+ return;
1161
+ const isError = raw.is_error ?? false;
1162
+ this.resolveTaskCreate(toolUseId, raw.content, event.timestamp, isError);
1163
+ this.resolveAgentResult(toolUseId, isError, event.timestamp);
1164
+ }
1165
+ }
1166
+ applyTaskUpdate(input) {
1167
+ const taskId = input.taskId;
1168
+ if (!taskId)
1169
+ return;
1170
+ const existing = this.tasks.get(taskId);
1171
+ if (existing) {
1172
+ if (input.status) {
1173
+ const newStatus = input.status;
1174
+ if (newStatus === 'deleted') {
1175
+ this.tasks.delete(taskId);
1176
+ if (this.activeTaskId === taskId)
1177
+ this.activeTaskId = null;
1178
+ return;
1179
+ }
1180
+ const oldStatus = existing.status;
1181
+ existing.status = newStatus;
1182
+ existing.updatedAt = new Date();
1183
+ if (newStatus === 'in_progress' && oldStatus !== 'in_progress') {
1184
+ this.activeTaskId = taskId;
1185
+ }
1186
+ else if (oldStatus === 'in_progress' && newStatus !== 'in_progress') {
1187
+ if (this.activeTaskId === taskId)
1188
+ this.activeTaskId = null;
1189
+ }
1190
+ }
1191
+ if (input.subject)
1192
+ existing.subject = input.subject;
1193
+ if (input.description)
1194
+ existing.description = input.description;
1195
+ if (input.activeForm)
1196
+ existing.activeForm = input.activeForm;
1197
+ if (input.addBlockedBy && Array.isArray(input.addBlockedBy)) {
1198
+ for (const id of input.addBlockedBy) {
1199
+ const idStr = String(id);
1200
+ if (!existing.blockedBy.includes(idStr)) {
1201
+ existing.blockedBy.push(idStr);
1202
+ }
1203
+ }
1204
+ }
1205
+ if (input.addBlocks && Array.isArray(input.addBlocks)) {
1206
+ for (const id of input.addBlocks) {
1207
+ const idStr = String(id);
1208
+ if (!existing.blocks.includes(idStr)) {
1209
+ existing.blocks.push(idStr);
1210
+ }
1211
+ }
1212
+ }
1213
+ // Re-evaluate goal gate after update
1214
+ existing.isGoalGate = this.isGoalGateTask(existing);
1215
+ }
1216
+ else {
1217
+ // TaskUpdate for unknown task — create placeholder
1218
+ const status = input.status || 'pending';
1219
+ if (status === 'deleted')
1220
+ return;
1221
+ const now = new Date();
1222
+ const task = {
1223
+ taskId,
1224
+ subject: input.subject || `Task ${taskId}`,
1225
+ description: input.description,
1226
+ status: status,
1227
+ createdAt: now,
1228
+ updatedAt: now,
1229
+ blockedBy: [],
1230
+ blocks: [],
1231
+ associatedToolCalls: [],
1232
+ activeForm: input.activeForm,
1233
+ };
1234
+ task.isGoalGate = this.isGoalGateTask(task);
1235
+ this.tasks.set(taskId, task);
1236
+ if (status === 'in_progress') {
1237
+ this.activeTaskId = taskId;
1238
+ }
1239
+ }
1240
+ }
1241
+ syncPlanStepFromTaskUpdate(input, now) {
1242
+ if (!this.planState || !input.status)
1243
+ return;
1244
+ const taskId = String(input.taskId ?? input.task_id ?? '');
1245
+ const stepId = taskId.startsWith('plan-') ? `step-${taskId.slice(5)}` : taskId;
1246
+ const step = this.planState.steps.find((candidate) => candidate.id === stepId);
1247
+ if (!step)
1248
+ return;
1249
+ const rawStatus = String(input.status).toLowerCase();
1250
+ const status = rawStatus === 'completed'
1251
+ ? 'completed'
1252
+ : rawStatus === 'in_progress' || rawStatus === 'in-progress'
1253
+ ? 'in_progress'
1254
+ : rawStatus === 'deleted'
1255
+ ? 'skipped'
1256
+ : 'pending';
1257
+ if (status === 'in_progress' && step.status !== 'in_progress') {
1258
+ step.startedAt = now.toISOString();
1259
+ }
1260
+ if (status === 'completed' && step.status !== status) {
1261
+ step.startedAt ??= now.toISOString();
1262
+ step.completedAt = now.toISOString();
1263
+ step.durationMs = Math.max(0, now.getTime() - new Date(step.startedAt).getTime());
1264
+ }
1265
+ step.status = status;
1266
+ const completed = this.planState.steps.filter((candidate) => candidate.status === 'completed');
1267
+ this.planState.completionRate =
1268
+ this.planState.steps.length > 0 ? completed.length / this.planState.steps.length : 0;
1269
+ this.planState.active = this.planState.steps.some((candidate) => candidate.status === 'pending' || candidate.status === 'in_progress');
1270
+ }
1271
+ resolveTaskCreate(toolUseId, content, timestamp, isError) {
1272
+ const pending = this.pendingTaskCreates.get(toolUseId);
1273
+ if (!pending)
1274
+ return;
1275
+ this.pendingTaskCreates.delete(toolUseId);
1276
+ // Don't create task on error
1277
+ if (isError)
1278
+ return;
1279
+ const taskId = this.extractTaskIdFromResult(content);
1280
+ if (!taskId)
1281
+ return;
1282
+ const now = new Date(timestamp);
1283
+ const task = {
1284
+ taskId,
1285
+ subject: pending.subject,
1286
+ description: pending.description,
1287
+ status: 'pending',
1288
+ createdAt: pending.timestamp ?? now,
1289
+ updatedAt: now,
1290
+ blockedBy: [],
1291
+ blocks: [],
1292
+ associatedToolCalls: [],
1293
+ activeForm: pending.activeForm,
1294
+ subagentType: pending.subagentType,
1295
+ isGoalGate: pending.isGoalGate,
1296
+ };
1297
+ // Evaluate goal gate from content if not explicitly set
1298
+ task.isGoalGate = task.isGoalGate || this.isGoalGateTask(task);
1299
+ this.tasks.set(taskId, task);
1300
+ }
1301
+ // ═══════════════════════════════════════════════════════════════════════
1302
+ // Private: TodoWrite handling (OpenCode)
1303
+ // ═══════════════════════════════════════════════════════════════════════
1304
+ handleTodoWrite(input, now) {
1305
+ const todos = input.todos;
1306
+ if (!Array.isArray(todos))
1307
+ return;
1308
+ // Remove all non-subagent tasks (subagent tasks should persist)
1309
+ for (const [taskId, task] of this.tasks) {
1310
+ if (!task.isSubagent) {
1311
+ this.tasks.delete(taskId);
1312
+ }
1313
+ }
1314
+ // Reset active task if it was a non-subagent task
1315
+ if (this.activeTaskId && !this.tasks.has(this.activeTaskId)) {
1316
+ this.activeTaskId = null;
1317
+ }
1318
+ for (let i = 0; i < todos.length; i++) {
1319
+ const todo = todos[i];
1320
+ const taskId = `todo-${i}`;
1321
+ const content = String(todo.content || todo.subject || `Todo ${i + 1}`);
1322
+ const rawStatus = String(todo.status || 'pending').toLowerCase();
1323
+ const priority = todo.priority ? String(todo.priority) : undefined;
1324
+ // Map OpenCode todo status values to TaskStatus
1325
+ let status;
1326
+ if (rawStatus === 'completed' || rawStatus === 'done') {
1327
+ status = 'completed';
1328
+ }
1329
+ else if (rawStatus === 'in_progress' || rawStatus === 'in-progress') {
1330
+ status = 'in_progress';
1331
+ }
1332
+ else {
1333
+ status = 'pending';
1334
+ }
1335
+ const task = {
1336
+ taskId,
1337
+ subject: content,
1338
+ description: priority ? `Priority: ${priority}` : undefined,
1339
+ status,
1340
+ createdAt: now,
1341
+ updatedAt: now,
1342
+ blockedBy: [],
1343
+ blocks: [],
1344
+ associatedToolCalls: [],
1345
+ };
1346
+ this.tasks.set(taskId, task);
1347
+ if (status === 'in_progress') {
1348
+ this.activeTaskId = taskId;
1349
+ }
1350
+ }
1351
+ // Second pass: parse dependency info from content text
1352
+ for (let i = 0; i < todos.length; i++) {
1353
+ const task = this.tasks.get(`todo-${i}`);
1354
+ if (!task)
1355
+ continue;
1356
+ // Check for explicit blockedBy field
1357
+ if (Array.isArray(todos[i].blockedBy)) {
1358
+ for (const ref of todos[i].blockedBy) {
1359
+ const refStr = String(ref);
1360
+ if (!task.blockedBy.includes(refStr)) {
1361
+ task.blockedBy.push(refStr);
1362
+ }
1363
+ }
1364
+ }
1365
+ // Parse dependency references from content text
1366
+ const deps = parseTodoDependencies(String(todos[i].content || ''), todos);
1367
+ for (const depId of deps) {
1368
+ if (depId !== `todo-${i}` && !task.blockedBy.includes(depId)) {
1369
+ task.blockedBy.push(depId);
1370
+ const depTask = this.tasks.get(depId);
1371
+ if (depTask && !depTask.blocks.includes(`todo-${i}`)) {
1372
+ depTask.blocks.push(`todo-${i}`);
1373
+ }
1374
+ }
1375
+ }
1376
+ }
1377
+ }
1378
+ // ═══════════════════════════════════════════════════════════════════════
1379
+ // Private: UpdatePlan handling (Codex)
1380
+ // ═══════════════════════════════════════════════════════════════════════
1381
+ handleUpdatePlan(input, now) {
1382
+ const plan = input.plan;
1383
+ if (!Array.isArray(plan))
1384
+ return;
1385
+ const seenPlanTaskIds = new Set();
1386
+ let activePlanTaskId = null;
1387
+ for (let i = 0; i < plan.length; i++) {
1388
+ const entry = plan[i];
1389
+ const step = String(entry.step || '').trim();
1390
+ if (!step)
1391
+ continue;
1392
+ const rawStatus = String(entry.status || 'pending').toLowerCase();
1393
+ let status;
1394
+ if (rawStatus === 'completed')
1395
+ status = 'completed';
1396
+ else if (rawStatus === 'in_progress' || rawStatus === 'in-progress')
1397
+ status = 'in_progress';
1398
+ else
1399
+ status = 'pending';
1400
+ const taskId = `plan-${i}`;
1401
+ seenPlanTaskIds.add(taskId);
1402
+ const existing = this.tasks.get(taskId);
1403
+ if (existing) {
1404
+ existing.subject = step;
1405
+ existing.status = status;
1406
+ existing.updatedAt = now;
1407
+ if (!existing.activeForm) {
1408
+ existing.activeForm = `Working on ${step}`;
1409
+ }
1410
+ }
1411
+ else {
1412
+ const task = {
1413
+ taskId,
1414
+ subject: step,
1415
+ status,
1416
+ createdAt: now,
1417
+ updatedAt: now,
1418
+ activeForm: `Working on ${step}`,
1419
+ blockedBy: [],
1420
+ blocks: [],
1421
+ associatedToolCalls: [],
1422
+ };
1423
+ this.tasks.set(taskId, task);
1424
+ }
1425
+ if (status === 'in_progress') {
1426
+ activePlanTaskId = taskId;
1427
+ }
1428
+ }
1429
+ // Mark missing synthetic plan tasks as deleted
1430
+ for (const [taskId, task] of this.tasks) {
1431
+ if (!taskId.startsWith('plan-'))
1432
+ continue;
1433
+ if (!seenPlanTaskIds.has(taskId) && task.status !== 'deleted') {
1434
+ task.status = 'deleted';
1435
+ task.updatedAt = now;
1436
+ }
1437
+ }
1438
+ if (activePlanTaskId) {
1439
+ this.activeTaskId = activePlanTaskId;
1440
+ }
1441
+ else if (this.activeTaskId?.startsWith('plan-')) {
1442
+ this.activeTaskId = null;
1443
+ }
1444
+ }
1445
+ // ═══════════════════════════════════════════════════════════════════════
1446
+ // Private: Agent/Task (subagent) as tracked task
1447
+ // ═══════════════════════════════════════════════════════════════════════
1448
+ handleAgentSpawn(toolUseId, input, now) {
1449
+ const agentTaskId = `agent-${toolUseId}`;
1450
+ const description = input.description ? String(input.description) : 'Subagent';
1451
+ const subagentType = input.subagent_type || input.subagentType || undefined;
1452
+ const task = {
1453
+ taskId: agentTaskId,
1454
+ subject: description,
1455
+ status: 'in_progress',
1456
+ createdAt: now,
1457
+ updatedAt: now,
1458
+ activeForm: subagentType ? `Running ${subagentType} agent` : 'Running subagent',
1459
+ blockedBy: [],
1460
+ blocks: [],
1461
+ associatedToolCalls: [],
1462
+ isSubagent: true,
1463
+ subagentType,
1464
+ toolUseId,
1465
+ };
1466
+ this.tasks.set(agentTaskId, task);
1467
+ }
1468
+ resolveAgentResult(toolUseId, isError, timestamp) {
1469
+ const agentTaskId = `agent-${toolUseId}`;
1470
+ const agentTask = this.tasks.get(agentTaskId);
1471
+ if (!agentTask)
1472
+ return;
1473
+ agentTask.status = isError ? 'deleted' : 'completed';
1474
+ agentTask.updatedAt = new Date(timestamp);
1475
+ }
1476
+ // ═══════════════════════════════════════════════════════════════════════
1477
+ // Private: Goal gate detection
1478
+ // ═══════════════════════════════════════════════════════════════════════
1479
+ isGoalGateTask(task) {
1480
+ if (GOAL_GATE_REGEX.test(task.subject))
1481
+ return true;
1482
+ if (task.description && GOAL_GATE_REGEX.test(task.description))
1483
+ return true;
1484
+ if (task.blocks.length >= 3)
1485
+ return true;
1486
+ return false;
1487
+ }
1488
+ // ═══════════════════════════════════════════════════════════════════════
1489
+ // Private: Subagent Tracking from SessionEvent
1490
+ // ═══════════════════════════════════════════════════════════════════════
1491
+ extractSubagentFromEvent(event) {
1492
+ const content = event.message?.content;
1493
+ if (!Array.isArray(content))
1494
+ return;
1495
+ for (const block of content) {
1496
+ if (block.type === 'tool_use' && block.name === 'Task') {
1497
+ const input = block.input;
1498
+ const toolUseId = block.id;
1499
+ if (!input || !toolUseId)
1500
+ continue;
1501
+ const info = {
1502
+ id: toolUseId,
1503
+ description: input.description || 'Unknown task',
1504
+ subagentType: input.subagent_type || input.subagentType || 'general',
1505
+ spawnTime: event.timestamp,
1506
+ status: 'running',
1507
+ };
1508
+ const idx = this.subagents.length;
1509
+ this.subagents.push(info);
1510
+ this.pendingSubagents.set(toolUseId, idx);
1511
+ }
1512
+ else if (block.type === 'tool_result') {
1513
+ const toolUseId = block.tool_use_id;
1514
+ if (!toolUseId)
1515
+ continue;
1516
+ this.completeSubagent(toolUseId, event.timestamp);
1517
+ }
1518
+ }
1519
+ }
1520
+ // ═══════════════════════════════════════════════════════════════════════
1521
+ // Private: Subagent Tracking from FollowEvent
1522
+ // ═══════════════════════════════════════════════════════════════════════
1523
+ extractSubagentFromFollowEvent(event) {
1524
+ const raw = event.raw;
1525
+ if (event.type === 'tool_use' && event.toolName === 'Task') {
1526
+ if (!raw?.input)
1527
+ return;
1528
+ const input = raw.input;
1529
+ const toolUseId = raw.id || '';
1530
+ const info = {
1531
+ id: toolUseId,
1532
+ description: input.description || 'Unknown task',
1533
+ subagentType: input.subagent_type || input.subagentType || 'general',
1534
+ spawnTime: event.timestamp,
1535
+ status: 'running',
1536
+ };
1537
+ const idx = this.subagents.length;
1538
+ this.subagents.push(info);
1539
+ if (toolUseId) {
1540
+ this.pendingSubagents.set(toolUseId, idx);
1541
+ }
1542
+ }
1543
+ else if (event.type === 'tool_result') {
1544
+ if (!raw)
1545
+ return;
1546
+ const toolUseId = raw.tool_use_id;
1547
+ if (!toolUseId)
1548
+ return;
1549
+ this.completeSubagent(toolUseId, event.timestamp);
1550
+ }
1551
+ }
1552
+ completeSubagent(toolUseId, timestamp) {
1553
+ const idx = this.pendingSubagents.get(toolUseId);
1554
+ if (idx === undefined)
1555
+ return;
1556
+ this.pendingSubagents.delete(toolUseId);
1557
+ const agent = this.subagents[idx];
1558
+ agent.status = 'completed';
1559
+ agent.completionTime = timestamp;
1560
+ const start = new Date(agent.spawnTime).getTime();
1561
+ const end = new Date(timestamp).getTime();
1562
+ if (!isNaN(start) && !isNaN(end) && end >= start) {
1563
+ agent.durationMs = end - start;
1564
+ }
1565
+ }
1566
+ // ═══════════════════════════════════════════════════════════════════════
1567
+ // Private: Plan Extraction from SessionEvent
1568
+ // ═══════════════════════════════════════════════════════════════════════
1569
+ extractPlanFromSessionEvent(event) {
1570
+ // Use toFollowEvents to properly split assistant messages into
1571
+ // per-content-block FollowEvents (one per tool_use + one for text).
1572
+ // This ensures PlanExtractor sees type:'tool_use' for plan tools
1573
+ // like EnterPlanMode, with raw.input set correctly.
1574
+ const providerId = this._providerId ?? 'claude-code';
1575
+ const followEvents = toFollowEvents(event, providerId);
1576
+ for (const fe of followEvents) {
1577
+ this.processPlanFollowEvent(fe);
1578
+ }
1579
+ }
1580
+ processPlanFollowEvent(event) {
1581
+ const now = new Date(event.timestamp);
1582
+ if (event.type === 'tool_use' && event.toolName === 'EnterPlanMode') {
1583
+ this.planEnteredAt = now;
1584
+ this.planState = {
1585
+ active: true,
1586
+ steps: [],
1587
+ enteredAt: now,
1588
+ source: event.providerId,
1589
+ };
1590
+ }
1591
+ const updated = this.planExtractor.processEvent(event);
1592
+ if (updated && this.planExtractor.plan) {
1593
+ this.planState = this.convertExtractedPlanToPlanState(this.planExtractor.plan, now);
1594
+ }
1595
+ if (event.type === 'tool_use' && event.toolName === 'ExitPlanMode' && this.planState) {
1596
+ this.planState.exitedAt = now;
1597
+ if (this.planState.enteredAt) {
1598
+ this.planState.totalDurationMs = now.getTime() - this.planState.enteredAt.getTime();
1599
+ }
1600
+ }
1601
+ }
1602
+ convertExtractedPlanToPlanState(extracted, now) {
1603
+ const previous = this.planState;
1604
+ const previousSteps = new Map((previous?.steps ?? []).map((step) => [`${step.id}\u0000${step.description}`, step]));
1605
+ const completed = extracted.steps.filter((s) => s.status === 'completed').length;
1606
+ const total = extracted.steps.length;
1607
+ const steps = extracted.steps.map((step) => {
1608
+ const old = previousSteps.get(`${step.id}\u0000${step.description}`);
1609
+ const merged = { ...step, ...old, status: step.status };
1610
+ if (step.status === 'in_progress' && old?.status !== 'in_progress' && !merged.startedAt) {
1611
+ merged.startedAt = now.toISOString();
1612
+ }
1613
+ if ((step.status === 'completed' || step.status === 'failed') &&
1614
+ old?.status !== step.status &&
1615
+ !merged.completedAt) {
1616
+ merged.startedAt ??= previous?.enteredAt?.toISOString() ?? now.toISOString();
1617
+ merged.completedAt = now.toISOString();
1618
+ merged.durationMs = Math.max(0, now.getTime() - new Date(merged.startedAt).getTime());
1619
+ }
1620
+ return merged;
1621
+ });
1622
+ return {
1623
+ active: steps.some((step) => step.status === 'pending' || step.status === 'in_progress'),
1624
+ steps,
1625
+ title: extracted.title,
1626
+ source: extracted.source,
1627
+ completionRate: total > 0 ? completed / total : 0,
1628
+ rawMarkdown: extracted.rawMarkdown,
1629
+ enteredAt: previous?.enteredAt ?? this.planEnteredAt ?? now,
1630
+ exitedAt: previous?.exitedAt,
1631
+ totalDurationMs: previous?.totalDurationMs,
1632
+ prompt: previous?.prompt,
1633
+ revision: previous?.revision,
1634
+ };
1635
+ }
1636
+ getActivePlanStep() {
1637
+ return this.planState?.steps.find((step) => step.status === 'in_progress');
1638
+ }
1639
+ attributePlanUsage(tokens, costUsd) {
1640
+ const step = this.getActivePlanStep();
1641
+ if (!step)
1642
+ return;
1643
+ step.tokensUsed = (step.tokensUsed ?? 0) + tokens;
1644
+ if (costUsd > 0)
1645
+ step.costUsd = (step.costUsd ?? 0) + costUsd;
1646
+ }
1647
+ attributePlanToolCall(toolName) {
1648
+ if (['EnterPlanMode', 'ExitPlanMode', 'UpdatePlan', 'TaskUpdate'].includes(toolName))
1649
+ return;
1650
+ const step = this.getActivePlanStep();
1651
+ if (step)
1652
+ step.toolCalls = (step.toolCalls ?? 0) + 1;
1653
+ }
1654
+ // ═══════════════════════════════════════════════════════════════════════
1655
+ // Private: Context Attribution from SessionEvent
1656
+ // ═══════════════════════════════════════════════════════════════════════
1657
+ attributeContextFromEvent(event) {
1658
+ const content = event.message?.content;
1659
+ if (event.type === 'user') {
1660
+ if (Array.isArray(content)) {
1661
+ for (const block of content) {
1662
+ if (block.type === 'tool_result') {
1663
+ const text = typeof block.content === 'string'
1664
+ ? block.content
1665
+ : JSON.stringify(block.content || '');
1666
+ this.contextAttribution.toolOutputs += this.estimateTokens(text);
1667
+ }
1668
+ else if (block.type === 'text') {
1669
+ const text = block.text || '';
1670
+ if (this.isSystemPromptContent(text)) {
1671
+ this.contextAttribution.systemPrompt += this.estimateTokens(text);
1672
+ }
1673
+ else {
1674
+ this.contextAttribution.userMessages += this.estimateTokens(text);
1675
+ }
1676
+ }
1677
+ }
1678
+ }
1679
+ else if (typeof content === 'string') {
1680
+ if (this.isSystemPromptContent(content)) {
1681
+ this.contextAttribution.systemPrompt += this.estimateTokens(content);
1682
+ }
1683
+ else {
1684
+ this.contextAttribution.userMessages += this.estimateTokens(content);
1685
+ }
1686
+ }
1687
+ }
1688
+ else if (event.type === 'assistant') {
1689
+ if (Array.isArray(content)) {
1690
+ for (const block of content) {
1691
+ if (block.type === 'thinking') {
1692
+ this.contextAttribution.thinking += this.estimateTokens(block.thinking || '');
1693
+ }
1694
+ else if (block.type === 'tool_use') {
1695
+ const input = typeof block.input === 'string' ? block.input : JSON.stringify(block.input || '');
1696
+ this.contextAttribution.toolInputs += this.estimateTokens(input);
1697
+ }
1698
+ else if (block.type === 'text') {
1699
+ this.contextAttribution.assistantResponses += this.estimateTokens(block.text || '');
1700
+ }
1701
+ }
1702
+ }
1703
+ }
1704
+ else if (event.type === 'summary') {
1705
+ const text = this.extractTextContent(event) || '';
1706
+ if (text) {
1707
+ this.contextAttribution.other += this.estimateTokens(text);
1708
+ }
1709
+ }
1710
+ else if (event.type === 'system') {
1711
+ const text = this.extractTextContent(event) || '';
1712
+ if (text) {
1713
+ this.contextAttribution.systemPrompt += this.estimateTokens(text);
1714
+ }
1715
+ }
1716
+ }
1717
+ // ═══════════════════════════════════════════════════════════════════════
1718
+ // Private: Context Attribution from FollowEvent
1719
+ // ═══════════════════════════════════════════════════════════════════════
1720
+ attributeContextFromFollowEvent(event) {
1721
+ const raw = event.raw;
1722
+ // Try to get full content blocks from raw.message.content
1723
+ const message = raw?.message;
1724
+ const content = message?.content;
1725
+ if (event.type === 'user') {
1726
+ if (Array.isArray(content)) {
1727
+ for (const block of content) {
1728
+ if (block.type === 'tool_result') {
1729
+ const text = typeof block.content === 'string'
1730
+ ? block.content
1731
+ : JSON.stringify(block.content || '');
1732
+ this.contextAttribution.toolOutputs += this.estimateTokens(text);
1733
+ }
1734
+ else if (block.type === 'text') {
1735
+ const text = block.text || '';
1736
+ if (this.isSystemPromptContent(text)) {
1737
+ this.contextAttribution.systemPrompt += this.estimateTokens(text);
1738
+ }
1739
+ else {
1740
+ this.contextAttribution.userMessages += this.estimateTokens(text);
1741
+ }
1742
+ }
1743
+ }
1744
+ }
1745
+ else if (event.summary) {
1746
+ if (this.isSystemPromptContent(event.summary)) {
1747
+ this.contextAttribution.systemPrompt += this.estimateTokens(event.summary);
1748
+ }
1749
+ else {
1750
+ this.contextAttribution.userMessages += this.estimateTokens(event.summary);
1751
+ }
1752
+ }
1753
+ }
1754
+ else if (event.type === 'assistant') {
1755
+ if (Array.isArray(content)) {
1756
+ for (const block of content) {
1757
+ if (block.type === 'thinking') {
1758
+ this.contextAttribution.thinking += this.estimateTokens(block.thinking || '');
1759
+ }
1760
+ else if (block.type === 'tool_use') {
1761
+ const input = typeof block.input === 'string' ? block.input : JSON.stringify(block.input || '');
1762
+ this.contextAttribution.toolInputs += this.estimateTokens(input);
1763
+ }
1764
+ else if (block.type === 'text') {
1765
+ this.contextAttribution.assistantResponses += this.estimateTokens(block.text || '');
1766
+ }
1767
+ }
1768
+ }
1769
+ else if (event.summary) {
1770
+ this.contextAttribution.assistantResponses += this.estimateTokens(event.summary);
1771
+ }
1772
+ }
1773
+ else if (event.type === 'tool_use') {
1774
+ // Try raw input first (summary is truncated to ~80 chars)
1775
+ const rawInput = raw?.input != null ? JSON.stringify(raw.input) : null;
1776
+ const text = rawInput || event.summary || '';
1777
+ if (text)
1778
+ this.contextAttribution.toolInputs += this.estimateTokens(text);
1779
+ }
1780
+ else if (event.type === 'tool_result') {
1781
+ const rawContent = raw?.content;
1782
+ const text = typeof rawContent === 'string'
1783
+ ? rawContent
1784
+ : rawContent
1785
+ ? JSON.stringify(rawContent)
1786
+ : event.summary || '';
1787
+ if (text)
1788
+ this.contextAttribution.toolOutputs += this.estimateTokens(text);
1789
+ }
1790
+ else if (event.type === 'summary') {
1791
+ if (event.summary) {
1792
+ this.contextAttribution.other += this.estimateTokens(event.summary);
1793
+ }
1794
+ }
1795
+ }
1796
+ // ═══════════════════════════════════════════════════════════════════════
1797
+ // Private: Permission Mode
1798
+ // ═══════════════════════════════════════════════════════════════════════
1799
+ trackPermissionMode(event) {
1800
+ if (!event.permissionMode)
1801
+ return;
1802
+ const mode = event.permissionMode;
1803
+ if (mode !== this.currentPermissionMode) {
1804
+ this.permissionModeHistory.push({
1805
+ timestamp: event.timestamp,
1806
+ mode,
1807
+ previousMode: this.currentPermissionMode,
1808
+ });
1809
+ this.currentPermissionMode = mode;
1810
+ }
1811
+ }
1812
+ // ═══════════════════════════════════════════════════════════════════════
1813
+ // Private: Timeline
1814
+ // ═══════════════════════════════════════════════════════════════════════
1815
+ addTimelineFromSessionEvent(event) {
1816
+ // Hard noise: skip entirely
1817
+ if (isHardNoise(event))
1818
+ return;
1819
+ let tlType;
1820
+ let description;
1821
+ let noiseLevel = 'system';
1822
+ const metadata = {};
1823
+ switch (event.type) {
1824
+ case 'user':
1825
+ tlType = 'user_prompt';
1826
+ description = this.extractTextContent(event) ?? 'User prompt';
1827
+ noiseLevel = 'user';
1828
+ break;
1829
+ case 'assistant':
1830
+ tlType = 'assistant_response';
1831
+ description = this.extractTextContent(event) ?? 'Assistant response';
1832
+ noiseLevel = 'ai';
1833
+ if (event.message.model)
1834
+ metadata.model = event.message.model;
1835
+ {
1836
+ // Cache-inclusive, like every other token total (see tokenSummary).
1837
+ const usage = extractNormalizedUsage(event);
1838
+ if (usage)
1839
+ metadata.tokenCount = usage.totalTokens;
1840
+ }
1841
+ break;
1842
+ case 'tool_use': {
1843
+ tlType = 'tool_call';
1844
+ // Use rich tool summary formatter
1845
+ const toolName = event.tool?.name ?? 'unknown';
1846
+ const toolSummary = event.tool?.input ? formatToolSummary(toolName, event.tool.input) : '';
1847
+ description = toolSummary ? `${toolName}: ${toolSummary}` : toolName;
1848
+ noiseLevel = 'system';
1849
+ if (event.tool)
1850
+ metadata.toolName = event.tool.name;
1851
+ break;
1852
+ }
1853
+ case 'tool_result':
1854
+ tlType = 'tool_result';
1855
+ description = event.result ? `Result for tool call` : 'Tool result';
1856
+ noiseLevel = 'noise';
1857
+ if (event.result?.is_error)
1858
+ metadata.isError = true;
1859
+ break;
1860
+ case 'summary':
1861
+ tlType = 'compaction';
1862
+ description = 'Context compacted';
1863
+ noiseLevel = 'system';
1864
+ break;
1865
+ case 'system':
1866
+ tlType = 'session_start';
1867
+ description =
1868
+ this.extractTextContent(event) ?? event.message?.sourceLabel ?? 'System event';
1869
+ noiseLevel = 'system';
1870
+ break;
1871
+ default:
1872
+ tlType = 'session_start';
1873
+ description = 'Event';
1874
+ break;
1875
+ }
1876
+ // Truncate description for timeline
1877
+ if (description.length > 200) {
1878
+ description = description.substring(0, 197) + '...';
1879
+ }
1880
+ // Classify noise
1881
+ const softNoiseReason = getSoftNoiseReason(event);
1882
+ const messageClassification = classifyMessage(event);
1883
+ this.timeline.push({
1884
+ type: tlType,
1885
+ timestamp: event.timestamp,
1886
+ description,
1887
+ noiseLevel,
1888
+ softNoiseReason: softNoiseReason ?? undefined,
1889
+ messageClassification,
1890
+ isSidechain: event.isSidechain,
1891
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
1892
+ });
1893
+ // Cap timeline
1894
+ if (this.timeline.length > this.timelineCap) {
1895
+ this.timeline.shift();
1896
+ }
1897
+ }
1898
+ addTimelineFromFollowEvent(event) {
1899
+ // Hard noise: skip entirely
1900
+ if (isHardNoiseFollowEvent(event))
1901
+ return;
1902
+ let tlType;
1903
+ let description = event.summary || '';
1904
+ let noiseLevel = 'system';
1905
+ const metadata = {};
1906
+ switch (event.type) {
1907
+ case 'user':
1908
+ tlType = 'user_prompt';
1909
+ noiseLevel = 'user';
1910
+ break;
1911
+ case 'assistant':
1912
+ tlType = 'assistant_response';
1913
+ noiseLevel = 'ai';
1914
+ if (event.model)
1915
+ metadata.model = event.model;
1916
+ if (event.tokens) {
1917
+ metadata.tokenCount =
1918
+ event.tokens.input +
1919
+ event.tokens.output +
1920
+ (event.cacheTokens?.read ?? 0) +
1921
+ (event.cacheTokens?.write ?? 0);
1922
+ }
1923
+ break;
1924
+ case 'tool_use':
1925
+ tlType = 'tool_call';
1926
+ noiseLevel = 'system';
1927
+ if (event.toolName)
1928
+ metadata.toolName = event.toolName;
1929
+ break;
1930
+ case 'tool_result':
1931
+ tlType = 'tool_result';
1932
+ noiseLevel = 'noise';
1933
+ break;
1934
+ case 'summary':
1935
+ tlType = 'compaction';
1936
+ noiseLevel = 'system';
1937
+ break;
1938
+ default:
1939
+ tlType = 'session_start';
1940
+ break;
1941
+ }
1942
+ // Truncate description for timeline
1943
+ if (description.length > 200) {
1944
+ description = description.substring(0, 197) + '...';
1945
+ }
1946
+ // Classify message
1947
+ const messageClassification = classifyFollowEvent(event);
1948
+ this.timeline.push({
1949
+ type: tlType,
1950
+ timestamp: event.timestamp,
1951
+ description,
1952
+ noiseLevel,
1953
+ messageClassification,
1954
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
1955
+ });
1956
+ // Cap timeline
1957
+ if (this.timeline.length > this.timelineCap) {
1958
+ this.timeline.shift();
1959
+ }
1960
+ }
1961
+ // ═══════════════════════════════════════════════════════════════════════
1962
+ // Private: Helper methods
1963
+ // ═══════════════════════════════════════════════════════════════════════
1964
+ estimateTokens(text) {
1965
+ return estimateTextTokens(text).count;
1966
+ }
1967
+ hasTextContent(event) {
1968
+ const content = event.message?.content;
1969
+ if (typeof content === 'string')
1970
+ return content.length > 0;
1971
+ if (Array.isArray(content)) {
1972
+ return content.some((b) => b.type === 'text' && typeof b.text === 'string' && b.text.length > 0);
1973
+ }
1974
+ return false;
1975
+ }
1976
+ extractTextContent(event) {
1977
+ const content = event.message?.content;
1978
+ if (typeof content === 'string')
1979
+ return content;
1980
+ if (Array.isArray(content)) {
1981
+ for (const block of content) {
1982
+ if (block.type === 'text' && typeof block.text === 'string') {
1983
+ return block.text;
1984
+ }
1985
+ }
1986
+ }
1987
+ return null;
1988
+ }
1989
+ extractTaskIdFromResult(content) {
1990
+ const str = typeof content === 'string' ? content : JSON.stringify(content || '');
1991
+ const taskMatch = str.match(/Task #(\d+)/i);
1992
+ if (taskMatch)
1993
+ return taskMatch[1];
1994
+ const jsonMatch = str.match(/"taskId"\s*:\s*"?(\d+)"?/i);
1995
+ if (jsonMatch)
1996
+ return jsonMatch[1];
1997
+ return null;
1998
+ }
1999
+ isSystemPromptContent(text) {
2000
+ return text.includes('<system-reminder>') || text.includes('CLAUDE.md');
2001
+ }
2002
+ // ═══════════════════════════════════════════════════════════════════════
2003
+ // Private: Gonzo/Lazyjournal analytics
2004
+ // ═══════════════════════════════════════════════════════════════════════
2005
+ extractToolNameFromEvent(event) {
2006
+ const content = event.message?.content;
2007
+ if (!Array.isArray(content))
2008
+ return undefined;
2009
+ for (const block of content) {
2010
+ if (block.type === 'tool_use' && typeof block.name === 'string') {
2011
+ return block.name;
2012
+ }
2013
+ }
2014
+ return undefined;
2015
+ }
2016
+ /** Feed event data into frequency trackers, pattern extractor, and heatmap. */
2017
+ feedAnalytics(timestamp, summary, toolName) {
2018
+ // Heatmap: record every event
2019
+ this.heatmapTracker.record(timestamp);
2020
+ // Tool frequency
2021
+ if (toolName) {
2022
+ this.toolFrequency.increment(toolName, timestamp);
2023
+ }
2024
+ // Word frequency: extract significant words from summary
2025
+ if (summary) {
2026
+ const words = summary.split(/\s+/).filter((w) => w.length > 2);
2027
+ for (const word of words) {
2028
+ this.wordFrequency.increment(word.toLowerCase(), timestamp);
2029
+ }
2030
+ // Pattern extraction
2031
+ this.patternExtractor.add(summary);
2032
+ }
2033
+ }
2034
+ }