@ericsanchezok/synergy-workbench 0.0.0-stage → 3.1.1

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 (153) hide show
  1. package/AGENTS.md +30 -0
  2. package/README.md +16 -2
  3. package/dist/agents.js +11 -0
  4. package/dist/cli-adapter.js +8 -0
  5. package/dist/component.js +65 -0
  6. package/dist/config-schema.js +265 -0
  7. package/dist/execution/activity.js +78 -0
  8. package/dist/execution/content.js +184 -0
  9. package/dist/execution/json-sections.js +231 -0
  10. package/dist/execution/presentation.js +79 -0
  11. package/dist/execution/preview.js +13 -0
  12. package/dist/execution/process.js +125 -0
  13. package/dist/execution/projection.js +327 -0
  14. package/dist/execution/query.js +18 -0
  15. package/dist/execution/routes.js +189 -0
  16. package/dist/execution/schema.js +322 -0
  17. package/dist/execution/service.js +1993 -0
  18. package/dist/features.js +22 -0
  19. package/dist/http.js +65 -0
  20. package/dist/performance/analysis.js +330 -0
  21. package/dist/performance/catalog.js +391 -0
  22. package/dist/performance/dashboard.js +483 -0
  23. package/dist/performance/error.js +15 -0
  24. package/dist/performance/index.js +6 -0
  25. package/dist/performance/inflight.js +43 -0
  26. package/dist/performance/projection.js +138 -0
  27. package/dist/performance/prompt/base.txt +38 -0
  28. package/dist/performance/prompt/builder.js +21 -0
  29. package/dist/performance/routes/performance-route.js +439 -0
  30. package/dist/performance/schema.js +628 -0
  31. package/dist/performance/timeline.js +98 -0
  32. package/dist/performance/trace-detail.js +91 -0
  33. package/dist/presentation/service.js +270 -0
  34. package/dist/presentation/tools/workbench-view.js +65 -0
  35. package/dist/presentation/tools.js +10 -0
  36. package/dist/project/directories.js +363 -0
  37. package/dist/project/git-health.js +498 -0
  38. package/dist/project/ide.js +66 -0
  39. package/dist/project/migration.js +22 -0
  40. package/dist/project/routes/directories.js +89 -0
  41. package/dist/project/routes/git.js +30 -0
  42. package/dist/project/routes/task-defaults.js +42 -0
  43. package/dist/project/session-health.js +9 -0
  44. package/dist/project/startup.js +28 -0
  45. package/dist/project/task-defaults.js +68 -0
  46. package/dist/project/tools/session-control.js +664 -0
  47. package/dist/project/tools/session-control.txt +60 -0
  48. package/dist/project/tools/worktree-archive.js +76 -0
  49. package/dist/project/tools/worktree-enter.js +175 -0
  50. package/dist/project/tools/worktree-leave.js +119 -0
  51. package/dist/project/tools/worktree-list.js +52 -0
  52. package/dist/project/tools.js +26 -0
  53. package/dist/project/vcs-branch-watcher.js +76 -0
  54. package/dist/project/vcs.js +114 -0
  55. package/dist/project/worktree-janitor.js +140 -0
  56. package/dist/project/worktrees.js +98 -0
  57. package/dist/push/bridge.js +171 -0
  58. package/dist/push/index.js +4 -0
  59. package/dist/push/routes/push.js +224 -0
  60. package/dist/push/service.js +93 -0
  61. package/dist/push/store.js +121 -0
  62. package/dist/push/types.js +58 -0
  63. package/dist/review/git.js +226 -0
  64. package/dist/review/routes.js +45 -0
  65. package/dist/review/schema.js +32 -0
  66. package/dist/review/state.js +79 -0
  67. package/dist/stats/aggregator.js +252 -0
  68. package/dist/stats/canonical.js +89 -0
  69. package/dist/stats/cli/stats.js +302 -0
  70. package/dist/stats/engine.js +176 -0
  71. package/dist/stats/index.js +5 -0
  72. package/dist/stats/rollup.js +460 -0
  73. package/dist/stats/routes/stats.js +94 -0
  74. package/dist/stats/routes/usage.js +83 -0
  75. package/dist/stats/storage.js +86 -0
  76. package/dist/stats/types.js +267 -0
  77. package/dist/worker.js +6 -0
  78. package/package.json +232 -4
  79. package/src/agents.ts +15 -0
  80. package/src/cli-adapter.ts +11 -0
  81. package/src/component.ts +73 -0
  82. package/src/config-schema.ts +289 -0
  83. package/src/execution/activity.ts +83 -0
  84. package/src/execution/content.ts +189 -0
  85. package/src/execution/json-sections.ts +230 -0
  86. package/src/execution/presentation.ts +91 -0
  87. package/src/execution/preview.ts +12 -0
  88. package/src/execution/process.ts +118 -0
  89. package/src/execution/projection.ts +330 -0
  90. package/src/execution/query.ts +19 -0
  91. package/src/execution/routes.ts +324 -0
  92. package/src/execution/schema.ts +336 -0
  93. package/src/execution/service.ts +2149 -0
  94. package/src/features.ts +22 -0
  95. package/src/http.ts +75 -0
  96. package/src/performance/analysis.ts +356 -0
  97. package/src/performance/catalog.ts +902 -0
  98. package/src/performance/dashboard.ts +521 -0
  99. package/src/performance/error.ts +32 -0
  100. package/src/performance/index.ts +6 -0
  101. package/src/performance/inflight.ts +46 -0
  102. package/src/performance/projection.ts +143 -0
  103. package/src/performance/prompt/base.txt +38 -0
  104. package/src/performance/prompt/builder.ts +24 -0
  105. package/src/performance/routes/performance-route.ts +541 -0
  106. package/src/performance/schema.ts +695 -0
  107. package/src/performance/timeline.ts +100 -0
  108. package/src/performance/trace-detail.ts +97 -0
  109. package/src/presentation/service.ts +303 -0
  110. package/src/presentation/tools/workbench-view.ts +75 -0
  111. package/src/presentation/tools.ts +11 -0
  112. package/src/project/directories.ts +391 -0
  113. package/src/project/git-health.ts +577 -0
  114. package/src/project/ide.ts +80 -0
  115. package/src/project/migration.ts +24 -0
  116. package/src/project/routes/directories.ts +124 -0
  117. package/src/project/routes/git.ts +42 -0
  118. package/src/project/routes/task-defaults.ts +50 -0
  119. package/src/project/session-health.ts +10 -0
  120. package/src/project/startup.ts +28 -0
  121. package/src/project/task-defaults.ts +70 -0
  122. package/src/project/tools/session-control.ts +725 -0
  123. package/src/project/tools/session-control.txt +60 -0
  124. package/src/project/tools/worktree-archive.ts +92 -0
  125. package/src/project/tools/worktree-enter.ts +227 -0
  126. package/src/project/tools/worktree-leave.ts +140 -0
  127. package/src/project/tools/worktree-list.ts +68 -0
  128. package/src/project/tools.ts +29 -0
  129. package/src/project/vcs-branch-watcher.ts +81 -0
  130. package/src/project/vcs.ts +124 -0
  131. package/src/project/worktree-janitor.ts +153 -0
  132. package/src/project/worktrees.ts +110 -0
  133. package/src/push/bridge.ts +182 -0
  134. package/src/push/index.ts +4 -0
  135. package/src/push/routes/push.ts +267 -0
  136. package/src/push/service.ts +108 -0
  137. package/src/push/store.ts +120 -0
  138. package/src/push/types.ts +67 -0
  139. package/src/review/git.ts +221 -0
  140. package/src/review/routes.ts +65 -0
  141. package/src/review/schema.ts +34 -0
  142. package/src/review/state.ts +77 -0
  143. package/src/stats/aggregator.ts +277 -0
  144. package/src/stats/canonical.ts +93 -0
  145. package/src/stats/cli/stats.ts +348 -0
  146. package/src/stats/engine.ts +197 -0
  147. package/src/stats/index.ts +5 -0
  148. package/src/stats/rollup.ts +522 -0
  149. package/src/stats/routes/stats.ts +107 -0
  150. package/src/stats/routes/usage.ts +103 -0
  151. package/src/stats/storage.ts +78 -0
  152. package/src/stats/types.ts +351 -0
  153. package/src/worker.ts +7 -0
@@ -0,0 +1,522 @@
1
+ import type { OperationDigest } from "./types"
2
+ import { ModelLimit } from "@ericsanchezok/synergy-util/model-limit"
3
+ import { RolloutAccounting } from "@ericsanchezok/synergy-harness/session/rollout/accounting"
4
+ import type {
5
+ AgentStats,
6
+ AgentUsage,
7
+ ChannelStats,
8
+ ChannelUsage,
9
+ CodeChangeStats,
10
+ DailyBucket,
11
+ HourlyBucket,
12
+ ModelStats,
13
+ ModelUsage,
14
+ OverviewStats,
15
+ SessionDigest,
16
+ SessionLifecycleStats,
17
+ StatsSnapshot,
18
+ TimeSeriesStats,
19
+ TokenBreakdown,
20
+ TokenCostStats,
21
+ ToolStats,
22
+ ToolUsage,
23
+ } from "./types"
24
+
25
+ export namespace Rollup {
26
+ // -----------------------------------------------------------------------
27
+ // Token helpers
28
+ // -----------------------------------------------------------------------
29
+
30
+ const ZERO_TOKENS: TokenBreakdown = {
31
+ input: 0,
32
+ output: 0,
33
+ reasoning: 0,
34
+ cache: { read: 0, write: 0 },
35
+ }
36
+
37
+ function addTokens(a: TokenBreakdown, b: TokenBreakdown): TokenBreakdown {
38
+ return {
39
+ input: a.input + b.input,
40
+ output: a.output + b.output,
41
+ reasoning: a.reasoning + b.reasoning,
42
+ cache: {
43
+ read: a.cache.read + b.cache.read,
44
+ write: a.cache.write + b.cache.write,
45
+ },
46
+ }
47
+ }
48
+
49
+ function totalTokens(t: TokenBreakdown): number {
50
+ return ModelLimit.totalTokens(t)
51
+ }
52
+
53
+ function dayKey(timestamp: number): string {
54
+ const d = new Date(timestamp)
55
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
56
+ }
57
+
58
+ function hourOfDay(timestamp: number): number {
59
+ return new Date(timestamp).getHours()
60
+ }
61
+
62
+ // -----------------------------------------------------------------------
63
+ // Dimension 1 — Overview
64
+ // -----------------------------------------------------------------------
65
+
66
+ function computeOverview(digests: SessionDigest[]): OverviewStats {
67
+ const archived = digests.filter((d) => d.archived !== undefined)
68
+ const daySet = new Set<string>()
69
+ for (const d of digests) daySet.add(dayKey(d.created))
70
+ const scopeSet = new Set<string>()
71
+ for (const d of digests) scopeSet.add(d.scopeID)
72
+
73
+ let longestStreak = 0
74
+ let currentStreak = 0
75
+ if (daySet.size > 0) {
76
+ const sortedDays = [...daySet].sort()
77
+ let prev = 0
78
+ for (const day of sortedDays) {
79
+ const ts = new Date(day).getTime()
80
+ if (prev > 0 && ts - prev <= 86_400_000) {
81
+ currentStreak++
82
+ } else {
83
+ currentStreak = 1
84
+ }
85
+ longestStreak = Math.max(longestStreak, currentStreak)
86
+ prev = ts
87
+ }
88
+ }
89
+
90
+ // Check if current streak extends to today
91
+ const today = dayKey(Date.now())
92
+ const sortedDays = [...daySet].sort()
93
+ if (sortedDays.length > 0 && sortedDays[sortedDays.length - 1] !== today) {
94
+ // Current streak already ended
95
+ }
96
+
97
+ return {
98
+ totalSessions: digests.length,
99
+ activeSessions: digests.length - archived.length,
100
+ archivedSessions: archived.length,
101
+ totalMessages: digests.reduce((s, d) => s + d.messages, 0),
102
+ totalTurns: digests.reduce((s, d) => s + d.turns, 0),
103
+ totalDays: daySet.size,
104
+ longestStreak,
105
+ currentStreak,
106
+ projectCount: scopeSet.size,
107
+ }
108
+ }
109
+
110
+ // -----------------------------------------------------------------------
111
+ // Dimension 2 — Tokens & Cost
112
+ // -----------------------------------------------------------------------
113
+
114
+ function computeTokenCost(digests: Array<SessionDigest | OperationDigest>): TokenCostStats {
115
+ const tokens = digests.reduce((acc, d) => addTokens(acc, d.tokens), { ...ZERO_TOKENS })
116
+ const cost = digests.reduce((s, d) => s + d.cost, 0)
117
+ const turns = digests.reduce((s, d) => s + d.turns, 0)
118
+ const days = new Set(digests.map((d) => dayKey(d.created))).size || 1
119
+ const cacheTotal = tokens.input + tokens.cache.read + tokens.cache.write
120
+ const total = totalTokens(tokens)
121
+
122
+ return {
123
+ tokens,
124
+ cost,
125
+ accounting: RolloutAccounting.merge(digests.flatMap((d) => (d.accounting ? [d.accounting] : []))),
126
+ cacheHitRate: cacheTotal > 0 ? tokens.cache.read / cacheTotal : 0,
127
+ avgCostPerTurn: turns > 0 ? cost / turns : 0,
128
+ avgTokensPerTurn: turns > 0 ? total / turns : 0,
129
+ dailyCost: cost / days,
130
+ dailyTokens: total / days,
131
+ }
132
+ }
133
+
134
+ // -----------------------------------------------------------------------
135
+ // Dimension 3 — By Model
136
+ // -----------------------------------------------------------------------
137
+
138
+ function computeModels(digests: Array<SessionDigest | OperationDigest>): ModelStats {
139
+ const map = new Map<string, ModelUsage>()
140
+ for (const d of digests) {
141
+ for (const [key, usage] of Object.entries(d.modelUsage)) {
142
+ const existing = map.get(key)
143
+ if (existing) {
144
+ const previousCalls = existing.accounting
145
+ ? existing.accounting.calls + existing.accounting.legacy.messages
146
+ : existing.messages
147
+ const incomingCalls = usage.accounting
148
+ ? usage.accounting.calls + usage.accounting.legacy.messages
149
+ : usage.messages
150
+ const responseMs = existing.avgResponseMs * previousCalls + usage.totalResponseMs
151
+ existing.messages += usage.messages
152
+ existing.turns += usage.messages
153
+ existing.tokens = addTokens(existing.tokens, usage.tokens)
154
+ existing.cost += usage.cost
155
+ existing.accounting = RolloutAccounting.merge([
156
+ existing.accounting ?? RolloutAccounting.empty(),
157
+ usage.accounting ?? RolloutAccounting.empty(),
158
+ ])
159
+ existing.avgResponseMs = previousCalls + incomingCalls > 0 ? responseMs / (previousCalls + incomingCalls) : 0
160
+ } else {
161
+ const [providerID, modelID] = key.split("/")
162
+ map.set(key, {
163
+ providerID: providerID ?? "",
164
+ modelID: modelID ?? "",
165
+ messages: usage.messages,
166
+ turns: usage.messages,
167
+ tokens: { ...usage.tokens, cache: { ...usage.tokens.cache } },
168
+ cost: usage.cost,
169
+ accounting: usage.accounting,
170
+ avgResponseMs:
171
+ usage.totalResponseMs /
172
+ Math.max(
173
+ 1,
174
+ usage.accounting ? usage.accounting.calls + usage.accounting.legacy.messages : usage.messages,
175
+ ),
176
+ })
177
+ }
178
+ }
179
+ }
180
+ const models = [...map.values()].sort((a, b) => b.messages - a.messages)
181
+ return { models }
182
+ }
183
+
184
+ // -----------------------------------------------------------------------
185
+ // Dimension 4 — By Agent
186
+ // -----------------------------------------------------------------------
187
+
188
+ function computeAgents(digests: Array<SessionDigest | OperationDigest>): AgentStats {
189
+ const map = new Map<string, AgentUsage>()
190
+ const sessionAgents = new Map<string, Set<string>>()
191
+ let totalSubagentCalls = 0
192
+
193
+ for (const d of digests) {
194
+ if ("parentID" in d && d.parentID) totalSubagentCalls++
195
+
196
+ for (const [agent, usage] of Object.entries(d.agentUsage)) {
197
+ const existing = map.get(agent)
198
+ if (existing) {
199
+ existing.messages += usage.messages
200
+ existing.tokens = addTokens(existing.tokens, usage.tokens)
201
+ existing.cost += usage.cost
202
+ existing.accounting = RolloutAccounting.merge([
203
+ existing.accounting ?? RolloutAccounting.empty(),
204
+ usage.accounting ?? RolloutAccounting.empty(),
205
+ ])
206
+ } else {
207
+ map.set(agent, {
208
+ agent,
209
+ messages: usage.messages,
210
+ sessions: 0,
211
+ tokens: { ...usage.tokens, cache: { ...usage.tokens.cache } },
212
+ cost: usage.cost,
213
+ accounting: usage.accounting,
214
+ subagentInvocations: 0,
215
+ })
216
+ }
217
+
218
+ if ("sessionID" in d) {
219
+ if (!sessionAgents.has(d.sessionID)) sessionAgents.set(d.sessionID, new Set())
220
+ sessionAgents.get(d.sessionID)!.add(agent)
221
+ }
222
+ }
223
+ }
224
+
225
+ // Count sessions per agent
226
+ for (const agents of sessionAgents.values()) {
227
+ for (const agent of agents) {
228
+ const entry = map.get(agent)
229
+ if (entry) entry.sessions++
230
+ }
231
+ }
232
+
233
+ // Subagent invocations: for each session that IS a subagent (has parentID),
234
+ // credit its agent
235
+ for (const d of digests) {
236
+ if ("parentID" in d && d.parentID) {
237
+ // The agent of this subagent session
238
+ const topAgent = Object.keys(d.agentUsage)[0]
239
+ if (topAgent) {
240
+ const entry = map.get(topAgent)
241
+ if (entry) entry.subagentInvocations++
242
+ }
243
+ }
244
+ }
245
+
246
+ const agents = [...map.values()].sort((a, b) => b.messages - a.messages)
247
+ return { agents, totalSubagentCalls }
248
+ }
249
+
250
+ // -----------------------------------------------------------------------
251
+ // Dimension 5 — By Tool
252
+ // -----------------------------------------------------------------------
253
+
254
+ function computeTools(digests: SessionDigest[]): ToolStats {
255
+ const map = new Map<string, ToolUsage>()
256
+ const timings = new Map<string, { duration: number; samples: number }>()
257
+ for (const d of digests) {
258
+ for (const [tool, usage] of Object.entries(d.toolUsage)) {
259
+ const timing = timings.get(tool) ?? { duration: 0, samples: 0 }
260
+ timing.duration += usage.totalDurationMs
261
+ timing.samples += usage.timedSamples ?? usage.calls
262
+ timings.set(tool, timing)
263
+ const existing = map.get(tool)
264
+ if (existing) {
265
+ existing.calls += usage.calls
266
+ existing.successes += usage.successes
267
+ existing.errors += usage.errors
268
+ existing.avgDurationMs = timing.samples > 0 ? timing.duration / timing.samples : 0
269
+ } else {
270
+ map.set(tool, {
271
+ tool,
272
+ calls: usage.calls,
273
+ successes: usage.successes,
274
+ errors: usage.errors,
275
+ avgDurationMs: timing.samples > 0 ? timing.duration / timing.samples : 0,
276
+ })
277
+ }
278
+ }
279
+ }
280
+ const tools = [...map.values()].sort((a, b) => b.calls - a.calls)
281
+ return { tools }
282
+ }
283
+
284
+ // -----------------------------------------------------------------------
285
+ // Dimension 6 — Code Changes
286
+ // -----------------------------------------------------------------------
287
+
288
+ function computeCodeChanges(digests: SessionDigest[]): CodeChangeStats {
289
+ const totalAdditions = digests.reduce((s, d) => s + d.additions, 0)
290
+ const totalDeletions = digests.reduce((s, d) => s + d.deletions, 0)
291
+ const days = new Set(digests.map((d) => dayKey(d.created))).size || 1
292
+
293
+ return {
294
+ totalAdditions,
295
+ totalDeletions,
296
+ totalFiles: digests.reduce((s, d) => s + d.files, 0),
297
+ netLines: totalAdditions - totalDeletions,
298
+ dailyAdditions: totalAdditions / days,
299
+ dailyDeletions: totalDeletions / days,
300
+ }
301
+ }
302
+
303
+ // -----------------------------------------------------------------------
304
+ // Dimension 7 — Session Lifecycle
305
+ // -----------------------------------------------------------------------
306
+
307
+ function computeLifecycle(digests: SessionDigest[]): SessionLifecycleStats {
308
+ const pinnedCount = digests.filter((d) => d.pinned).length
309
+ const turnsPerSession = digests.map((d) => d.turns).sort((a, b) => a - b)
310
+ const avgTurns =
311
+ turnsPerSession.length > 0 ? turnsPerSession.reduce((s, t) => s + t, 0) / turnsPerSession.length : 0
312
+ const mid = Math.floor(turnsPerSession.length / 2)
313
+ const medianTurns =
314
+ turnsPerSession.length === 0
315
+ ? 0
316
+ : turnsPerSession.length % 2 === 0
317
+ ? (turnsPerSession[mid - 1] + turnsPerSession[mid]) / 2
318
+ : turnsPerSession[mid]
319
+
320
+ const compactionCount = digests.reduce((s, d) => s + d.compactionCount, 0)
321
+ const retryCount = digests.reduce((s, d) => s + d.retryCount, 0)
322
+ const errorCount = digests.reduce((s, d) => s + d.errorCount, 0)
323
+ const totalMessages = digests.reduce((s, d) => s + d.messages, 0)
324
+
325
+ let short = 0
326
+ let medium = 0
327
+ let long = 0
328
+ for (const d of digests) {
329
+ if (d.durationMs < 5 * 60_000) short++
330
+ else if (d.durationMs < 30 * 60_000) medium++
331
+ else long++
332
+ }
333
+
334
+ return {
335
+ pinnedCount,
336
+ avgTurnsPerSession: avgTurns,
337
+ medianTurnsPerSession: medianTurns,
338
+ compactionCount,
339
+ retryCount,
340
+ errorCount,
341
+ errorRate: totalMessages > 0 ? errorCount / totalMessages : 0,
342
+ durationBuckets: { short, medium, long },
343
+ }
344
+ }
345
+
346
+ // -----------------------------------------------------------------------
347
+ // Dimension 8 — By Channel
348
+ // -----------------------------------------------------------------------
349
+
350
+ function computeChannels(digests: SessionDigest[]): ChannelStats {
351
+ const map = new Map<string, ChannelUsage>()
352
+ let interactiveSessions = 0
353
+ let unattendedSessions = 0
354
+
355
+ for (const d of digests) {
356
+ const channel = d.endpoint?.type ?? d.endpoint?.kind ?? "web"
357
+ const existing = map.get(channel)
358
+ if (existing) {
359
+ existing.sessions++
360
+ existing.messages += d.messages
361
+ } else {
362
+ map.set(channel, { channel, sessions: 1, messages: d.messages })
363
+ }
364
+
365
+ if (d.interaction?.mode === "unattended") unattendedSessions++
366
+ else interactiveSessions++
367
+ }
368
+
369
+ const channels = [...map.values()].sort((a, b) => b.sessions - a.sessions)
370
+ return { channels, interactiveSessions, unattendedSessions }
371
+ }
372
+
373
+ // -----------------------------------------------------------------------
374
+ // Dimension 9 — Time Series
375
+ // -----------------------------------------------------------------------
376
+
377
+ function computeTimeSeries(digests: SessionDigest[]): TimeSeriesStats {
378
+ const dayMap = new Map<string, DailyBucket>()
379
+ const hourMap = new Map<string, number>()
380
+ const hourlyActivity = new Array(24).fill(0) as number[]
381
+
382
+ for (const d of digests) {
383
+ const day = dayKey(d.created)
384
+ const existing = dayMap.get(day)
385
+ if (existing) {
386
+ existing.sessions++
387
+ existing.turns += d.turns
388
+ existing.tokens = addTokens(existing.tokens, d.tokens)
389
+ existing.cost += d.cost
390
+ existing.accounting = RolloutAccounting.merge([
391
+ existing.accounting ?? RolloutAccounting.empty(),
392
+ d.accounting ?? RolloutAccounting.empty(),
393
+ ])
394
+ existing.additions += d.additions
395
+ existing.deletions += d.deletions
396
+ existing.files += d.files
397
+ const totalToolCalls = Object.values(d.toolUsage).reduce((s, u) => s + u.calls, 0)
398
+ existing.toolCalls += totalToolCalls
399
+ existing.errors += d.errorCount
400
+ } else {
401
+ const totalToolCalls = Object.values(d.toolUsage).reduce((s, u) => s + u.calls, 0)
402
+ dayMap.set(day, {
403
+ day,
404
+ sessions: 1,
405
+ turns: d.turns,
406
+ tokens: { ...d.tokens, cache: { ...d.tokens.cache } },
407
+ cost: d.cost,
408
+ accounting: d.accounting,
409
+ additions: d.additions,
410
+ deletions: d.deletions,
411
+ files: d.files,
412
+ toolCalls: totalToolCalls,
413
+ errors: d.errorCount,
414
+ })
415
+ }
416
+
417
+ for (const [hour, turns] of Object.entries(d.hourlyTurns ?? {})) {
418
+ hourMap.set(hour, (hourMap.get(hour) ?? 0) + turns)
419
+ const hourNumber = Number(hour.slice(-2))
420
+ hourlyActivity[hourNumber] += turns
421
+ }
422
+ }
423
+
424
+ const days = [...dayMap.values()].sort((a, b) => a.day.localeCompare(b.day))
425
+ const hours: HourlyBucket[] = [...hourMap.entries()]
426
+ .sort((a, b) => a[0].localeCompare(b[0]))
427
+ .map(([hour, turns]) => ({ hour, turns }))
428
+ return { days, hours, hourlyActivity }
429
+ }
430
+
431
+ // -----------------------------------------------------------------------
432
+ // Full snapshot
433
+ // -----------------------------------------------------------------------
434
+
435
+ export function snapshot(
436
+ digests: SessionDigest[],
437
+ watermark: number,
438
+ operations: OperationDigest[] = [],
439
+ ): StatsSnapshot {
440
+ const usage = [...digests, ...operations]
441
+ const timeSeries = computeTimeSeries(digests)
442
+ const days = new Map(timeSeries.days.map((day) => [day.day, day]))
443
+ for (const operation of operations) {
444
+ const day = dayKey(operation.created)
445
+ days.set(
446
+ day,
447
+ mergeDailyBucket(days.get(day), {
448
+ day,
449
+ sessions: 0,
450
+ turns: 0,
451
+ tokens: operation.tokens,
452
+ cost: operation.cost,
453
+ accounting: operation.accounting,
454
+ additions: 0,
455
+ deletions: 0,
456
+ files: 0,
457
+ toolCalls: 0,
458
+ errors: 0,
459
+ }),
460
+ )
461
+ }
462
+ timeSeries.days = [...days.values()].sort((a, b) => a.day.localeCompare(b.day))
463
+ return {
464
+ overview: computeOverview(digests),
465
+ tokenCost: computeTokenCost(usage),
466
+ models: computeModels(usage),
467
+ agents: computeAgents(usage),
468
+ tools: computeTools(digests),
469
+ codeChanges: computeCodeChanges(digests),
470
+ lifecycle: computeLifecycle(digests),
471
+ channels: computeChannels(digests),
472
+ timeSeries,
473
+ computedAt: Date.now(),
474
+ watermark,
475
+ }
476
+ }
477
+
478
+ // -----------------------------------------------------------------------
479
+ // Incremental daily bucket merge
480
+ // -----------------------------------------------------------------------
481
+
482
+ export function mergeDailyBucket(existing: DailyBucket | undefined, incoming: DailyBucket): DailyBucket {
483
+ if (!existing) return incoming
484
+ return {
485
+ day: incoming.day,
486
+ sessions: existing.sessions + incoming.sessions,
487
+ turns: existing.turns + incoming.turns,
488
+ tokens: addTokens(existing.tokens, incoming.tokens),
489
+ cost: existing.cost + incoming.cost,
490
+ accounting: RolloutAccounting.merge([
491
+ existing.accounting ?? RolloutAccounting.empty(),
492
+ incoming.accounting ?? RolloutAccounting.empty(),
493
+ ]),
494
+ additions: existing.additions + incoming.additions,
495
+ deletions: existing.deletions + incoming.deletions,
496
+ files: existing.files + incoming.files,
497
+ toolCalls: existing.toolCalls + incoming.toolCalls,
498
+ errors: existing.errors + incoming.errors,
499
+ }
500
+ }
501
+
502
+ // -----------------------------------------------------------------------
503
+ // Sub-digest from a single session (for daily bucket)
504
+ // -----------------------------------------------------------------------
505
+
506
+ export function sessionToDailyBucket(d: SessionDigest): DailyBucket {
507
+ const totalToolCalls = Object.values(d.toolUsage).reduce((s, u) => s + u.calls, 0)
508
+ return {
509
+ day: dayKey(d.created),
510
+ sessions: 1,
511
+ turns: d.turns,
512
+ tokens: { ...d.tokens, cache: { ...d.tokens.cache } },
513
+ cost: d.cost,
514
+ ...(d.accounting ? { accounting: d.accounting } : {}),
515
+ additions: d.additions,
516
+ deletions: d.deletions,
517
+ files: d.files,
518
+ toolCalls: totalToolCalls,
519
+ errors: d.errorCount,
520
+ }
521
+ }
522
+ }
@@ -0,0 +1,107 @@
1
+ import { Hono } from "hono"
2
+ import { streamSSE } from "hono/streaming"
3
+ import { describeRoute, validator, resolver } from "hono-openapi"
4
+ import z from "zod"
5
+ import { errors } from "@ericsanchezok/synergy-server/server/error"
6
+ import { Engine } from ".."
7
+ import { ProgressEvent, StatsSnapshot } from "../types"
8
+ import { UsageRoute } from "./usage"
9
+
10
+ const StatsQuery = z.object({
11
+ recompute: z
12
+ .enum(["true", "false"])
13
+ .optional()
14
+ .default("false")
15
+ .meta({ description: "Set to 'true' to force a full recompute from scratch" }),
16
+ })
17
+
18
+ export const StatsRoute = () =>
19
+ new Hono()
20
+ .route("/usage", UsageRoute())
21
+ .get(
22
+ "/",
23
+ describeRoute({
24
+ summary: "Get stats snapshot",
25
+ description:
26
+ "Read the last computed stats snapshot without scanning history; null means no snapshot exists. Use the progress stream to refresh, or ?recompute=true to force a full recompute.",
27
+ operationId: "global.stats.get",
28
+ responses: {
29
+ 200: {
30
+ description: "Stats snapshot",
31
+ content: {
32
+ "application/json": {
33
+ schema: resolver(StatsSnapshot.meta({ ref: "StatsSnapshot" }).nullable()),
34
+ },
35
+ },
36
+ },
37
+ ...errors(400),
38
+ },
39
+ }),
40
+ validator("query", StatsQuery),
41
+ async (c) => {
42
+ const { recompute } = c.req.valid("query")
43
+ try {
44
+ const snapshot = recompute === "true" ? await Engine.recompute() : await Engine.get()
45
+ return c.json(snapshot)
46
+ } catch (err: any) {
47
+ return c.json({ message: err?.message ?? String(err) }, 400)
48
+ }
49
+ },
50
+ )
51
+ .get(
52
+ "/progress",
53
+ describeRoute({
54
+ summary: "Stream stats recompute progress",
55
+ description:
56
+ "Refresh changed statistics and stream progress updates until the final snapshot is ready. Concurrent refreshes share one computation.",
57
+ operationId: "global.stats.progress",
58
+ responses: {
59
+ 200: {
60
+ description: "Server-sent events containing progress and final snapshot payloads",
61
+ content: {
62
+ "text/event-stream": {
63
+ schema: resolver(
64
+ z.object({
65
+ type: z.enum(["progress", "done", "error"]),
66
+ progress: ProgressEvent.optional(),
67
+ snapshot: StatsSnapshot.optional(),
68
+ message: z.string().optional(),
69
+ }),
70
+ ),
71
+ },
72
+ },
73
+ },
74
+ },
75
+ }),
76
+ async (c) => {
77
+ c.header("X-Accel-Buffering", "no")
78
+ c.header("Cache-Control", "no-cache, no-transform")
79
+ return streamSSE(c, async (stream) => {
80
+ try {
81
+ const snapshot = await Engine.update(async (event) => {
82
+ await stream.writeSSE({
83
+ data: JSON.stringify({
84
+ type: "progress",
85
+ progress: event,
86
+ }),
87
+ })
88
+ })
89
+ await stream.writeSSE({
90
+ data: JSON.stringify({
91
+ type: "done",
92
+ snapshot,
93
+ }),
94
+ })
95
+ } catch (err: any) {
96
+ await stream.writeSSE({
97
+ data: JSON.stringify({
98
+ type: "error",
99
+ message: err?.message ?? String(err),
100
+ }),
101
+ })
102
+ } finally {
103
+ stream.close()
104
+ }
105
+ })
106
+ },
107
+ )