@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,93 @@
1
+ import { Usage } from "@ericsanchezok/synergy-harness/usage"
2
+ import { RolloutAccounting } from "@ericsanchezok/synergy-harness/session/rollout/accounting"
3
+ import type { StatsSnapshot, DailyBucket } from "./types"
4
+ import { ExecutionPresentation } from "../execution/presentation"
5
+
6
+ export namespace CanonicalStats {
7
+ function projection(accounting: RolloutAccounting.Summary) {
8
+ return {
9
+ ...RolloutAccounting.project(accounting),
10
+ cost: RolloutAccounting.knownExpense(accounting),
11
+ costPresentation: ExecutionPresentation.cost(accounting),
12
+ accounting,
13
+ }
14
+ }
15
+ export async function apply(snapshot: StatsSnapshot): Promise<StatsSnapshot> {
16
+ const usage = await Usage.summary()
17
+ const days = new Map<string, DailyBucket>(
18
+ snapshot.timeSeries.days.map((day) => [
19
+ day.day,
20
+ {
21
+ ...day,
22
+ ...projection(RolloutAccounting.empty()),
23
+ toolCalls: 0,
24
+ },
25
+ ]),
26
+ )
27
+ for (const day of usage.daily)
28
+ days.set(day.date, {
29
+ day: day.date,
30
+ sessions: 0,
31
+ turns: 0,
32
+ additions: 0,
33
+ deletions: 0,
34
+ files: 0,
35
+ errors: 0,
36
+ ...days.get(day.date),
37
+ ...projection(day.accounting),
38
+ toolCalls: day.toolCalls,
39
+ })
40
+ const total = projection(usage.accounting)
41
+ const turns = snapshot.overview.totalTurns
42
+ const activeDays = usage.daily.filter((day) => day.accounting.calls || day.accounting.legacy.messages).length || 1
43
+ return {
44
+ ...snapshot,
45
+ tokenCost: {
46
+ ...total,
47
+ cacheHitRate: usage.cache.ratio ?? usage.cache.observedRatio ?? 0,
48
+ avgCostPerTurn: turns ? total.cost / turns : 0,
49
+ avgTokensPerTurn: turns ? usage.accounting.tokens.total.known / turns : 0,
50
+ dailyCost: total.cost / activeDays,
51
+ dailyTokens: usage.accounting.tokens.total.known / activeDays,
52
+ },
53
+ models: {
54
+ models: usage.models.map((model) => {
55
+ const previous = snapshot.models.models.find(
56
+ (value) => value.providerID === model.providerID && value.modelID === model.modelID,
57
+ )
58
+ return {
59
+ providerID: model.providerID,
60
+ modelID: model.modelID,
61
+ messages: previous?.messages ?? 0,
62
+ turns: previous?.turns ?? 0,
63
+ avgResponseMs: previous?.avgResponseMs ?? 0,
64
+ ...projection(model.accounting),
65
+ }
66
+ }),
67
+ },
68
+ agents: {
69
+ ...snapshot.agents,
70
+ agents: Object.entries(usage.agents).map(([agent, accounting]) => {
71
+ const previous = snapshot.agents.agents.find((value) => value.agent === agent)
72
+ return {
73
+ agent,
74
+ messages: previous?.messages ?? 0,
75
+ sessions: previous?.sessions ?? 0,
76
+ subagentInvocations: previous?.subagentInvocations ?? 0,
77
+ ...projection(accounting),
78
+ }
79
+ }),
80
+ },
81
+ tools: {
82
+ tools: usage.tools.map((tool) => ({
83
+ tool: tool.tool,
84
+ calls: tool.calls,
85
+ successes: tool.completed,
86
+ errors: tool.failed,
87
+ avgDurationMs: tool.averageMs ?? 0,
88
+ })),
89
+ },
90
+ timeSeries: { ...snapshot.timeSeries, days: [...days.values()].sort((a, b) => a.day.localeCompare(b.day)) },
91
+ }
92
+ }
93
+ }
@@ -0,0 +1,348 @@
1
+ import type { Argv } from "yargs"
2
+ import { cmd } from "@ericsanchezok/synergy-util/cli-command"
3
+ import { UI } from "@ericsanchezok/synergy-util/terminal"
4
+ import { Engine } from ".."
5
+ import type { StatsSnapshot, ProgressCallback } from ".."
6
+ import { RolloutQuery } from "@ericsanchezok/synergy-harness/session/rollout/query"
7
+
8
+ export const StatsCommand = cmd({
9
+ command: "stats",
10
+ describe: "show token usage and cost statistics",
11
+ builder: (yargs: Argv) => {
12
+ return yargs
13
+ .option("run", { type: "string", describe: "Show one run and its descendant accounting" })
14
+ .option("compare", {
15
+ type: "string",
16
+ array: true,
17
+ nargs: 2,
18
+ describe: "Compare two runs without inferring task quality",
19
+ })
20
+ .option("days", {
21
+ describe: "show stats for the last N days (default: all time)",
22
+ type: "number",
23
+ })
24
+ .option("tools", {
25
+ describe: "number of tools to show (default: all)",
26
+ type: "number",
27
+ })
28
+ .option("models", {
29
+ describe: "show model statistics (default: hidden). Pass a number to show top N, otherwise shows all",
30
+ })
31
+ .option("project", {
32
+ describe: "filter by project (default: all projects, empty string: current project)",
33
+ type: "string",
34
+ })
35
+ .option("recompute", {
36
+ describe: "force full recompute from scratch",
37
+ type: "boolean",
38
+ default: false,
39
+ })
40
+ .option("json", {
41
+ describe: "output raw JSON instead of formatted display",
42
+ type: "boolean",
43
+ default: false,
44
+ })
45
+ },
46
+ handler: async (args) => {
47
+ if (args.run || args.compare) {
48
+ if (args.run && args.compare) throw new Error("Use either --run or --compare")
49
+ const result = args.run
50
+ ? await RolloutQuery.find(args.run)
51
+ : await Promise.all(args.compare!.map((id) => RolloutQuery.find(id)))
52
+ console.log(JSON.stringify(Array.isArray(result) ? RolloutQuery.compare(result[0], result[1]) : result, null, 2))
53
+ return
54
+ }
55
+ const onProgress: ProgressCallback = (event) => {
56
+ if (args.json) return
57
+ const pct = event.total > 0 ? Math.round((event.current / event.total) * 100) : 0
58
+ const bar = UI.progressBar({ ratio: event.current / Math.max(1, event.total), width: 30, brackets: true })
59
+ const msg = event.message ?? event.phase
60
+ process.stdout.write(`\r ${bar} ${pct}% ${msg}`)
61
+ if (event.current === event.total && event.phase === "snapshot") {
62
+ process.stdout.write("\r\x1B[K")
63
+ }
64
+ }
65
+
66
+ let snapshot: StatsSnapshot
67
+
68
+ if (args.project !== undefined) {
69
+ console.log("Note: project-scoped filtering requires full recomputation.")
70
+ snapshot = await Engine.recompute(onProgress)
71
+ } else if (args.recompute) {
72
+ snapshot = await Engine.recompute(onProgress)
73
+ } else {
74
+ snapshot = await Engine.update(onProgress)
75
+ }
76
+
77
+ // Apply --days filter client-side on time series
78
+ if (args.days !== undefined) {
79
+ snapshot = filterByDays(snapshot, args.days)
80
+ }
81
+
82
+ if (args.json) {
83
+ console.log(JSON.stringify(snapshot, null, 2))
84
+ return
85
+ }
86
+
87
+ let modelLimit: number | undefined
88
+ if (args.models === true) {
89
+ modelLimit = Infinity
90
+ } else if (typeof args.models === "number") {
91
+ modelLimit = args.models
92
+ }
93
+
94
+ displayStats(snapshot, args.tools, modelLimit)
95
+ },
96
+ })
97
+
98
+ function filterByDays(snapshot: StatsSnapshot, days: number): StatsSnapshot {
99
+ const MS_IN_DAY = 24 * 60 * 60 * 1000
100
+ const cutoff =
101
+ days === 0
102
+ ? (() => {
103
+ const d = new Date()
104
+ d.setHours(0, 0, 0, 0)
105
+ return d.getTime()
106
+ })()
107
+ : Date.now() - days * MS_IN_DAY
108
+
109
+ const filteredDays = snapshot.timeSeries.days.filter((d) => new Date(d.day).getTime() >= cutoff)
110
+
111
+ return {
112
+ ...snapshot,
113
+ timeSeries: {
114
+ ...snapshot.timeSeries,
115
+ days: filteredDays,
116
+ },
117
+ }
118
+ }
119
+
120
+ export function displayStats(snapshot: StatsSnapshot, toolLimit?: number, modelLimit?: number) {
121
+ const width = 72
122
+
123
+ function renderRow(label: string, value: string): string {
124
+ const availableWidth = width - 1
125
+ const paddingNeeded = availableWidth - label.length - value.length
126
+ const padding = Math.max(0, paddingNeeded)
127
+ return `│${label}${" ".repeat(padding)}${value} │`
128
+ }
129
+
130
+ function renderHeader(title: string): void {
131
+ const innerWidth = width - 2
132
+ const padTotal = innerWidth - title.length
133
+ const padLeft = Math.floor(padTotal / 2)
134
+ const padRight = padTotal - padLeft
135
+ console.log(`│${" ".repeat(padLeft)}${title}${" ".repeat(padRight)}│`)
136
+ }
137
+
138
+ function topBorder(): void {
139
+ console.log(`┌${"─".repeat(width - 2)}┐`)
140
+ }
141
+
142
+ function divider(): void {
143
+ console.log(`├${"─".repeat(width - 2)}┤`)
144
+ }
145
+
146
+ function bottomBorder(): void {
147
+ console.log(`└${"─".repeat(width - 2)}┘`)
148
+ }
149
+
150
+ const { overview, tokenCost, models, agents, tools, codeChanges, lifecycle, channels } = snapshot
151
+
152
+ // ── Overview ──────────────────────────────────────────────
153
+ topBorder()
154
+ renderHeader("OVERVIEW")
155
+ divider()
156
+ console.log(
157
+ renderRow(
158
+ "Sessions",
159
+ `${overview.totalSessions.toLocaleString()} (active: ${overview.activeSessions}, archived: ${overview.archivedSessions})`,
160
+ ),
161
+ )
162
+ console.log(renderRow("Messages", overview.totalMessages.toLocaleString()))
163
+ console.log(renderRow("Turns", overview.totalTurns.toLocaleString()))
164
+ console.log(renderRow("Days Active", overview.totalDays.toString()))
165
+ console.log(renderRow("Longest Streak", overview.longestStreak.toString()))
166
+ console.log(renderRow("Current Streak", overview.currentStreak.toString()))
167
+ console.log(renderRow("Projects", overview.projectCount.toString()))
168
+ bottomBorder()
169
+ console.log()
170
+
171
+ // ── Tokens & Cost ─────────────────────────────────────────
172
+ topBorder()
173
+ renderHeader("TOKENS & COST")
174
+ divider()
175
+ console.log(renderRow("Known API Estimate", `$${tokenCost.cost.toFixed(2)}`))
176
+ if (tokenCost.accounting) {
177
+ const accounting = tokenCost.accounting
178
+ console.log(renderRow("Unpriced API Attempts", accounting.apiEstimate.unknown.toString()))
179
+ console.log(renderRow("Unobserved Calls", accounting.unobservedCalls.toString()))
180
+ console.log(renderRow("Subscription API Equivalent", `$${accounting.subscriptionEquivalent.known.toFixed(4)}`))
181
+ console.log(renderRow("Unknown Subscription Estimates", accounting.subscriptionEquivalent.unknown.toString()))
182
+ console.log(renderRow("Historical Calculation", `$${accounting.legacy.cost.toFixed(4)}`))
183
+ for (const [currency, amount] of Object.entries(accounting.reported.currencies))
184
+ console.log(renderRow(`Provider Reported (${currency})`, amount.toFixed(4)))
185
+ }
186
+ console.log(renderRow("Avg Cost/Turn", `$${tokenCost.avgCostPerTurn.toFixed(4)}`))
187
+ console.log(renderRow("Daily Cost", `$${tokenCost.dailyCost.toFixed(2)}`))
188
+ console.log(renderRow("Input Tokens", formatNumber(tokenCost.tokens.input)))
189
+ console.log(renderRow("Output Tokens", formatNumber(tokenCost.tokens.output)))
190
+ console.log(renderRow("Reasoning (included in output)", formatNumber(tokenCost.tokens.reasoning)))
191
+ console.log(renderRow("Cache Read", formatNumber(tokenCost.tokens.cache.read)))
192
+ console.log(renderRow("Cache Write", formatNumber(tokenCost.tokens.cache.write)))
193
+ console.log(renderRow("Cache Hit Rate", `${(tokenCost.cacheHitRate * 100).toFixed(1)}%`))
194
+ console.log(renderRow("Avg Tokens/Turn", formatNumber(Math.round(tokenCost.avgTokensPerTurn))))
195
+ console.log(renderRow("Daily Tokens", formatNumber(Math.round(tokenCost.dailyTokens))))
196
+ bottomBorder()
197
+ console.log()
198
+
199
+ // ── Models ────────────────────────────────────────────────
200
+ if (modelLimit !== undefined && models.models.length > 0) {
201
+ const sorted = [...models.models].sort((a, b) => b.messages - a.messages)
202
+ const toDisplay = modelLimit === Infinity ? sorted : sorted.slice(0, modelLimit)
203
+
204
+ topBorder()
205
+ renderHeader("MODEL USAGE")
206
+ divider()
207
+
208
+ for (const m of toDisplay) {
209
+ const modelKey = `${m.providerID}/${m.modelID}`
210
+ console.log(renderRow(modelKey, ""))
211
+ console.log(renderRow(" Messages", m.messages.toLocaleString()))
212
+ console.log(renderRow(" Input Tokens", formatNumber(m.tokens.input)))
213
+ console.log(renderRow(" Output Tokens", formatNumber(m.tokens.output)))
214
+ console.log(renderRow(" Known API Estimate", `$${m.cost.toFixed(4)}`))
215
+ console.log(renderRow(" Avg Response", `${m.avgResponseMs.toFixed(0)}ms`))
216
+ divider()
217
+ }
218
+ // Replace last divider with bottom border
219
+ process.stdout.write("\x1B[1A")
220
+ bottomBorder()
221
+ console.log()
222
+ }
223
+
224
+ // ── Agents ────────────────────────────────────────────────
225
+ if (agents.agents.length > 0) {
226
+ const sorted = [...agents.agents].sort((a, b) => b.messages - a.messages)
227
+
228
+ topBorder()
229
+ renderHeader("AGENT USAGE")
230
+ divider()
231
+
232
+ for (const a of sorted) {
233
+ console.log(renderRow(a.agent, ""))
234
+ console.log(renderRow(" Messages", a.messages.toLocaleString()))
235
+ console.log(renderRow(" Sessions", a.sessions.toLocaleString()))
236
+ console.log(renderRow(" Known API Estimate", `$${a.cost.toFixed(4)}`))
237
+ console.log(renderRow(" Subagent Calls", a.subagentInvocations.toLocaleString()))
238
+ divider()
239
+ }
240
+ process.stdout.write("\x1B[1A")
241
+ bottomBorder()
242
+ console.log()
243
+
244
+ if (agents.totalSubagentCalls > 0) {
245
+ console.log(` Total subagent calls: ${agents.totalSubagentCalls.toLocaleString()}`)
246
+ console.log()
247
+ }
248
+ }
249
+
250
+ // ── Tools ─────────────────────────────────────────────────
251
+ if (tools.tools.length > 0) {
252
+ const sorted = [...tools.tools].sort((a, b) => b.calls - a.calls)
253
+ const toDisplay = toolLimit ? sorted.slice(0, toolLimit) : sorted
254
+ const maxCalls = Math.max(...toDisplay.map((t) => t.calls))
255
+ const totalCalls = tools.tools.reduce((sum, t) => sum + t.calls, 0)
256
+
257
+ topBorder()
258
+ renderHeader("TOOL USAGE")
259
+ divider()
260
+
261
+ for (const t of toDisplay) {
262
+ const ratio = t.calls / maxCalls
263
+ const bar = UI.progressBar({ ratio, width: 20, brackets: false })
264
+ const percentage = totalCalls > 0 ? ((t.calls / totalCalls) * 100).toFixed(1) : "0.0"
265
+ const successRate = t.calls > 0 ? ((t.successes / t.calls) * 100).toFixed(0) : "—"
266
+
267
+ const maxToolLength = 18
268
+ const truncatedTool = t.tool.length > maxToolLength ? t.tool.substring(0, maxToolLength - 2) + ".." : t.tool
269
+ const toolName = truncatedTool.padEnd(maxToolLength)
270
+
271
+ const content = ` ${toolName} ${bar.padEnd(20)} ${t.calls.toString().padStart(4)} (${percentage.padStart(4)}%) ${successRate.padStart(3)}%ok`
272
+ const padding = Math.max(0, width - content.length - 1)
273
+ console.log(`│${content}${" ".repeat(padding)} │`)
274
+ }
275
+
276
+ divider()
277
+ const avgDurLine = ` Avg duration per tool (ms):`
278
+ console.log(`│${avgDurLine}${" ".repeat(width - avgDurLine.length - 2)} │`)
279
+ for (const t of toDisplay) {
280
+ const durText = ` ${t.tool}: ${t.avgDurationMs.toFixed(0)}ms`
281
+ const padding = Math.max(0, width - durText.length - 2)
282
+ console.log(`│${durText}${" ".repeat(padding)} │`)
283
+ }
284
+
285
+ bottomBorder()
286
+ console.log()
287
+ }
288
+
289
+ // ── Code Changes ──────────────────────────────────────────
290
+ if (codeChanges.totalAdditions > 0 || codeChanges.totalDeletions > 0) {
291
+ topBorder()
292
+ renderHeader("CODE CHANGES")
293
+ divider()
294
+ console.log(renderRow("Additions", formatNumber(codeChanges.totalAdditions)))
295
+ console.log(renderRow("Deletions", formatNumber(codeChanges.totalDeletions)))
296
+ console.log(renderRow("Net Lines", formatNumber(codeChanges.netLines)))
297
+ console.log(renderRow("Files Touched", formatNumber(codeChanges.totalFiles)))
298
+ console.log(renderRow("Daily Additions", formatNumber(Math.round(codeChanges.dailyAdditions))))
299
+ console.log(renderRow("Daily Deletions", formatNumber(Math.round(codeChanges.dailyDeletions))))
300
+ bottomBorder()
301
+ console.log()
302
+ }
303
+
304
+ // ── Lifecycle ─────────────────────────────────────────────
305
+ topBorder()
306
+ renderHeader("LIFECYCLE")
307
+ divider()
308
+ console.log(renderRow("Pinned Sessions", lifecycle.pinnedCount.toLocaleString()))
309
+ console.log(renderRow("Avg Turns/Session", lifecycle.avgTurnsPerSession.toFixed(1)))
310
+ console.log(renderRow("Median Turns/Session", lifecycle.medianTurnsPerSession.toFixed(1)))
311
+ console.log(renderRow("Compactions", lifecycle.compactionCount.toLocaleString()))
312
+ console.log(renderRow("Retries", lifecycle.retryCount.toLocaleString()))
313
+ console.log(renderRow("Error Rate", `${(lifecycle.errorRate * 100).toFixed(1)}%`))
314
+ console.log(
315
+ renderRow(
316
+ "Duration (short/med/long)",
317
+ `${lifecycle.durationBuckets.short}/${lifecycle.durationBuckets.medium}/${lifecycle.durationBuckets.long}`,
318
+ ),
319
+ )
320
+ bottomBorder()
321
+ console.log()
322
+
323
+ // ── Channels ──────────────────────────────────────────────
324
+ if (channels.channels.length > 0) {
325
+ topBorder()
326
+ renderHeader("CHANNELS")
327
+ divider()
328
+
329
+ for (const c of channels.channels) {
330
+ console.log(renderRow(c.channel, `sessions: ${c.sessions}, messages: ${c.messages}`))
331
+ }
332
+
333
+ divider()
334
+ console.log(renderRow("Interactive", channels.interactiveSessions.toLocaleString()))
335
+ console.log(renderRow("Unattended", channels.unattendedSessions.toLocaleString()))
336
+ bottomBorder()
337
+ console.log()
338
+ }
339
+ }
340
+
341
+ function formatNumber(num: number): string {
342
+ if (num >= 1000000) {
343
+ return (num / 1000000).toFixed(1) + "M"
344
+ } else if (num >= 1000) {
345
+ return (num / 1000).toFixed(1) + "K"
346
+ }
347
+ return num.toString()
348
+ }
@@ -0,0 +1,197 @@
1
+ import { RolloutSnapshot } from "@ericsanchezok/synergy-harness/session/rollout/snapshot"
2
+ import { OperationDigest } from "./types"
3
+ import { Lock } from "@ericsanchezok/synergy-harness/util/lock"
4
+ import { readRolloutRevision } from "@ericsanchezok/synergy-harness/rollout"
5
+ import type { Info as SessionInfo } from "@ericsanchezok/synergy-harness/session/types"
6
+ import { Storage } from "@ericsanchezok/synergy-harness/storage/storage"
7
+ import { StoragePath } from "@ericsanchezok/synergy-harness/storage/path"
8
+ import { Aggregator } from "./aggregator"
9
+ import { StatsStorage } from "./storage"
10
+ import { Rollup } from "./rollup"
11
+ import { CanonicalStats } from "./canonical"
12
+ import { Usage } from "@ericsanchezok/synergy-harness/usage"
13
+ import type { StatsWatermark, StatsSnapshot, ProgressCallback } from "./types"
14
+
15
+ export namespace Engine {
16
+ // -----------------------------------------------------------------------
17
+ // Public API
18
+ // -----------------------------------------------------------------------
19
+
20
+ /**
21
+ * Run incremental stats update: scan sessions changed since watermark,
22
+ * update digests + daily buckets, recompute snapshot.
23
+ */
24
+ export async function update(onProgress?: ProgressCallback): Promise<StatsSnapshot> {
25
+ return requestRefresh(false, onProgress)
26
+ }
27
+
28
+ const jobs = Storage.state(
29
+ () =>
30
+ new Map<
31
+ boolean,
32
+ {
33
+ promise: Promise<StatsSnapshot>
34
+ listeners: Set<ProgressCallback>
35
+ }
36
+ >(),
37
+ )
38
+
39
+ function requestRefresh(full: boolean, onProgress?: ProgressCallback): Promise<StatsSnapshot> {
40
+ const running = jobs()
41
+ const existing = running.get(full) ?? (!full ? running.get(true) : undefined)
42
+ if (existing) {
43
+ if (onProgress) existing.listeners.add(onProgress)
44
+ return existing.promise.finally(() => {
45
+ if (onProgress) existing.listeners.delete(onProgress)
46
+ })
47
+ }
48
+ const listeners = new Set<ProgressCallback>(onProgress ? [onProgress] : [])
49
+ const promise = refresh(full, (event) => {
50
+ for (const listener of listeners)
51
+ void Promise.resolve()
52
+ .then(() => listener(event))
53
+ .catch(() => listeners.delete(listener))
54
+ }).finally(() => running.delete(full))
55
+ running.set(full, { promise, listeners })
56
+ return promise
57
+ }
58
+
59
+ async function operationDigests() {
60
+ const result: OperationDigest[] = []
61
+ const retained = new Set<string>()
62
+ for (const scopeID of await Storage.scan(["operations"])) {
63
+ for (const operationID of await Storage.scan(["operations", scopeID])) {
64
+ const owner = { kind: "operation" as const, scopeID, operationID }
65
+ const revision = await readRolloutRevision(owner)
66
+ if (!revision) continue
67
+ const key = StoragePath.statsOperation(scopeID, operationID)
68
+ retained.add(key.join("/"))
69
+ const cached = await Storage.read(key).catch((error) => {
70
+ if (error instanceof Storage.NotFoundError) return undefined
71
+ throw error
72
+ })
73
+ const parsed = OperationDigest.safeParse(cached)
74
+ if (parsed.success && parsed.data.rolloutRevision === revision) {
75
+ result.push(parsed.data)
76
+ continue
77
+ }
78
+ const digest = Aggregator.operation(await RolloutSnapshot.read(owner, { revision }))
79
+ await Storage.write(key, digest)
80
+ result.push(digest)
81
+ }
82
+ }
83
+ for (const scopeID of await Storage.scan(StoragePath.statsOperations()))
84
+ for (const id of await Storage.scan([...StoragePath.statsOperations(), scopeID])) {
85
+ const key = StoragePath.statsOperation(scopeID, id)
86
+ if (!retained.has(key.join("/"))) await Storage.remove(key)
87
+ }
88
+ return result
89
+ }
90
+
91
+ async function refresh(full: boolean, onProgress?: ProgressCallback): Promise<StatsSnapshot> {
92
+ using lock = await Lock.write("stats-update")
93
+ const watermark = full ? undefined : await StatsStorage.getWatermark()
94
+
95
+ onProgress?.({ phase: "scan", current: 0, total: 1, message: "Scanning sessions..." })
96
+ const allSessions = await getAllSessions()
97
+
98
+ let newOrUpdated: SessionInfo[]
99
+ let deletedIDs: string[]
100
+
101
+ if (watermark) {
102
+ const knownSet = new Set(watermark.sessionIDs)
103
+ const currentMap = new Map(allSessions.map((s) => [s.id, s]))
104
+
105
+ // Sessions that are new or updated since watermark
106
+ newOrUpdated = []
107
+ for (let offset = 0; offset < allSessions.length; offset += 20) {
108
+ const batch = allSessions.slice(offset, offset + 20)
109
+ const changed = await Promise.all(
110
+ batch.map(async (session) => {
111
+ if (!knownSet.has(session.id) || session.time.updated > watermark.lastUpdated) return true
112
+ const [digest, revision] = await Promise.all([
113
+ StatsStorage.getDigest(session.id),
114
+ readRolloutRevision({ kind: "session", scopeID: session.scope.id, sessionID: session.id }),
115
+ ])
116
+ return digest?.rolloutRevision !== revision
117
+ }),
118
+ )
119
+ newOrUpdated.push(...batch.filter((_, index) => changed[index]))
120
+ }
121
+
122
+ // Sessions that were known but no longer exist
123
+ deletedIDs = watermark.sessionIDs.filter((id) => !currentMap.has(id))
124
+ } else {
125
+ newOrUpdated = allSessions
126
+ deletedIDs = []
127
+ }
128
+
129
+ // Digest new/updated sessions with progress
130
+ const freshDigests = await Aggregator.digestAll(newOrUpdated, (current, total) => {
131
+ onProgress?.({ phase: "digest", current, total, message: `Digesting sessions ${current}/${total}...` })
132
+ })
133
+
134
+ // Write new/updated digests
135
+ for (const d of freshDigests) {
136
+ await Usage.preserveOwner({ kind: "session", scopeID: d.scopeID, sessionID: d.sessionID })
137
+ await StatsStorage.setDigest(d)
138
+ }
139
+
140
+ // Remove digests for deleted sessions
141
+ for (const id of deletedIDs) {
142
+ await StatsStorage.removeDigest(id)
143
+ }
144
+
145
+ // Load all digests for full snapshot
146
+ onProgress?.({ phase: "snapshot", current: 0, total: 1, message: "Computing snapshot..." })
147
+ const allDigests = await StatsStorage.getAllDigests()
148
+
149
+ // Compute new watermark
150
+ const maxUpdated = allSessions.length > 0 ? Math.max(...allSessions.map((s) => s.time.updated)) : 0
151
+ const newWatermark: StatsWatermark = {
152
+ lastUpdated: maxUpdated,
153
+ sessionIDs: allSessions.map((s) => s.id),
154
+ lastFullScanAt: Date.now(),
155
+ }
156
+
157
+ // Compute and store snapshot
158
+ const snapshot = await CanonicalStats.apply(Rollup.snapshot(allDigests, maxUpdated, await operationDigests()))
159
+ onProgress?.({
160
+ phase: "bucket",
161
+ current: 0,
162
+ total: snapshot.timeSeries.days.length,
163
+ message: "Updating daily buckets...",
164
+ })
165
+ const days = new Set(snapshot.timeSeries.days.map((day) => day.day))
166
+ for (const day of snapshot.timeSeries.days) await StatsStorage.setDailyBucket(day.day, day)
167
+ for (const day of await StatsStorage.listDailyKeys()) {
168
+ if (!days.has(day)) await Storage.remove(StoragePath.statsDaily(day))
169
+ }
170
+ await StatsStorage.setSnapshot(snapshot)
171
+ await StatsStorage.setWatermark(newWatermark)
172
+
173
+ onProgress?.({ phase: "snapshot", current: 1, total: 1, message: "Done" })
174
+ return snapshot
175
+ }
176
+
177
+ export async function get(): Promise<StatsSnapshot | null> {
178
+ return (await StatsStorage.getSnapshot()) ?? null
179
+ }
180
+
181
+ /**
182
+ * Force full recompute from scratch (clears all cached stats).
183
+ */
184
+ export async function recompute(onProgress?: ProgressCallback): Promise<StatsSnapshot> {
185
+ return requestRefresh(true, onProgress)
186
+ }
187
+
188
+ // -----------------------------------------------------------------------
189
+ // Internal helpers
190
+ // -----------------------------------------------------------------------
191
+
192
+ async function getAllSessions(): Promise<SessionInfo[]> {
193
+ const sessions: SessionInfo[] = []
194
+ for await (const record of Storage.records<SessionInfo>({ kind: "session" })) sessions.push(record.value)
195
+ return sessions
196
+ }
197
+ }
@@ -0,0 +1,5 @@
1
+ export { Engine } from "./engine"
2
+ export { Aggregator } from "./aggregator"
3
+ export { Rollup } from "./rollup"
4
+ export { StatsStorage } from "./storage"
5
+ export * from "./types"