prjct-cli 0.44.1 → 0.45.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.
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Metrics Storage
3
+ *
4
+ * Manages value dashboard metrics via storage/metrics.json
5
+ * Generates context/metrics.md for Claude
6
+ *
7
+ * Tracks:
8
+ * - Token savings (compression)
9
+ * - Sync performance
10
+ * - Agent usage
11
+ * - Daily trends for visualization
12
+ */
13
+
14
+ import { StorageManager } from './storage-manager'
15
+ import { getTimestamp } from '../utils/date-helper'
16
+ import {
17
+ type MetricsJson,
18
+ type DailyStats,
19
+ type AgentUsage,
20
+ DEFAULT_METRICS,
21
+ estimateCostSaved,
22
+ formatCost,
23
+ } from '../schemas/metrics'
24
+
25
+ class MetricsStorage extends StorageManager<MetricsJson> {
26
+ constructor() {
27
+ super('metrics.json')
28
+ }
29
+
30
+ protected getDefault(): MetricsJson {
31
+ return { ...DEFAULT_METRICS }
32
+ }
33
+
34
+ protected getMdFilename(): string {
35
+ return 'metrics.md'
36
+ }
37
+
38
+ protected getLayer(): string {
39
+ return 'context'
40
+ }
41
+
42
+ protected getEventType(action: 'update' | 'create' | 'delete'): string {
43
+ return `metrics.${action}d`
44
+ }
45
+
46
+ protected toMarkdown(data: MetricsJson): string {
47
+ const lines = ['# Value Dashboard 📊', '']
48
+
49
+ if (data.syncCount === 0) {
50
+ lines.push('_No metrics yet. Run `prjct sync` to start tracking._')
51
+ lines.push('')
52
+ return lines.join('\n')
53
+ }
54
+
55
+ // Token Savings
56
+ lines.push('## 💰 Token Savings')
57
+ lines.push('')
58
+ lines.push(`- **Total saved**: ${this.formatTokens(data.totalTokensSaved)} tokens`)
59
+ lines.push(`- **Compression**: ${(data.avgCompressionRate * 100).toFixed(0)}% average reduction`)
60
+ lines.push(`- **Estimated cost saved**: ${formatCost(estimateCostSaved(data.totalTokensSaved))}`)
61
+ lines.push('')
62
+
63
+ // Performance
64
+ lines.push('## ⚡ Performance')
65
+ lines.push('')
66
+ lines.push(`- **Syncs completed**: ${data.syncCount.toLocaleString()}`)
67
+ lines.push(`- **Avg sync time**: ${this.formatDuration(data.avgSyncDuration)}`)
68
+ if (data.watchTriggers > 0) {
69
+ lines.push(`- **Watch triggers**: ${data.watchTriggers.toLocaleString()} auto-syncs`)
70
+ }
71
+ lines.push('')
72
+
73
+ // Agent Usage
74
+ if (data.agentUsage.length > 0) {
75
+ lines.push('## 🤖 Agent Usage')
76
+ lines.push('')
77
+ const sortedAgents = [...data.agentUsage].sort((a, b) => b.usageCount - a.usageCount)
78
+ const totalUsage = sortedAgents.reduce((sum, a) => sum + a.usageCount, 0)
79
+
80
+ sortedAgents.slice(0, 5).forEach(agent => {
81
+ const pct = totalUsage > 0 ? ((agent.usageCount / totalUsage) * 100).toFixed(0) : 0
82
+ lines.push(`- **${agent.agentName}**: ${pct}% (${agent.usageCount} uses)`)
83
+ })
84
+ lines.push('')
85
+ }
86
+
87
+ // Trend (last 30 days)
88
+ if (data.dailyStats.length > 0) {
89
+ lines.push('## 📈 30-Day Trend')
90
+ lines.push('')
91
+ const last30 = this.getLast30Days(data.dailyStats)
92
+ const totalLast30 = last30.reduce((sum, d) => sum + d.tokensSaved, 0)
93
+ lines.push(`- **Tokens saved**: ${this.formatTokens(totalLast30)}`)
94
+ lines.push(`- **Syncs**: ${last30.reduce((sum, d) => sum + d.syncs, 0)}`)
95
+ lines.push('')
96
+ lines.push('```')
97
+ lines.push(this.generateSparkline(last30))
98
+ lines.push('```')
99
+ lines.push('')
100
+ }
101
+
102
+ // Footer
103
+ lines.push('---')
104
+ lines.push('')
105
+ if (data.firstSync) {
106
+ lines.push(`_Tracking since ${new Date(data.firstSync).toLocaleDateString()}_`)
107
+ }
108
+ lines.push('')
109
+
110
+ return lines.join('\n')
111
+ }
112
+
113
+ // =========== Domain Methods ===========
114
+
115
+ /**
116
+ * Record a sync event with metrics
117
+ */
118
+ async recordSync(
119
+ projectId: string,
120
+ metrics: {
121
+ originalSize: number // Tokens before compression
122
+ filteredSize: number // Tokens after compression
123
+ duration: number // Sync duration in ms
124
+ isWatch?: boolean // From watch mode?
125
+ agents?: string[] // Agents used
126
+ }
127
+ ): Promise<void> {
128
+ const tokensSaved = Math.max(0, metrics.originalSize - metrics.filteredSize)
129
+ const compressionRate = metrics.originalSize > 0
130
+ ? tokensSaved / metrics.originalSize
131
+ : 0
132
+
133
+ const today = new Date().toISOString().split('T')[0]
134
+
135
+ await this.update(projectId, (data) => {
136
+ // Update totals
137
+ const newSyncCount = data.syncCount + 1
138
+ const newTotalTokensSaved = data.totalTokensSaved + tokensSaved
139
+ const newTotalDuration = data.totalSyncDuration + metrics.duration
140
+
141
+ // Running average for compression rate
142
+ const newAvgCompression = data.syncCount === 0
143
+ ? compressionRate
144
+ : (data.avgCompressionRate * data.syncCount + compressionRate) / newSyncCount
145
+
146
+ // Update daily stats
147
+ const dailyStats = [...data.dailyStats]
148
+ const todayIndex = dailyStats.findIndex(d => d.date === today)
149
+
150
+ if (todayIndex >= 0) {
151
+ const existing = dailyStats[todayIndex]
152
+ dailyStats[todayIndex] = {
153
+ ...existing,
154
+ tokensSaved: existing.tokensSaved + tokensSaved,
155
+ syncs: existing.syncs + 1,
156
+ avgCompressionRate: (existing.avgCompressionRate * existing.syncs + compressionRate) / (existing.syncs + 1),
157
+ totalDuration: existing.totalDuration + metrics.duration,
158
+ }
159
+ } else {
160
+ dailyStats.push({
161
+ date: today,
162
+ tokensSaved,
163
+ syncs: 1,
164
+ avgCompressionRate: compressionRate,
165
+ totalDuration: metrics.duration,
166
+ })
167
+ }
168
+
169
+ // Keep only last 90 days
170
+ const cutoff = new Date()
171
+ cutoff.setDate(cutoff.getDate() - 90)
172
+ const cutoffStr = cutoff.toISOString().split('T')[0]
173
+ const trimmedStats = dailyStats.filter(d => d.date >= cutoffStr)
174
+
175
+ // Update agent usage
176
+ const agentUsage = [...data.agentUsage]
177
+ if (metrics.agents) {
178
+ for (const agentName of metrics.agents) {
179
+ const idx = agentUsage.findIndex(a => a.agentName === agentName)
180
+ if (idx >= 0) {
181
+ agentUsage[idx] = {
182
+ ...agentUsage[idx],
183
+ usageCount: agentUsage[idx].usageCount + 1,
184
+ tokensSaved: agentUsage[idx].tokensSaved + Math.floor(tokensSaved / metrics.agents.length),
185
+ }
186
+ } else {
187
+ agentUsage.push({
188
+ agentName,
189
+ usageCount: 1,
190
+ tokensSaved: Math.floor(tokensSaved / metrics.agents.length),
191
+ })
192
+ }
193
+ }
194
+ }
195
+
196
+ return {
197
+ totalTokensSaved: newTotalTokensSaved,
198
+ avgCompressionRate: newAvgCompression,
199
+ syncCount: newSyncCount,
200
+ watchTriggers: data.watchTriggers + (metrics.isWatch ? 1 : 0),
201
+ avgSyncDuration: newTotalDuration / newSyncCount,
202
+ totalSyncDuration: newTotalDuration,
203
+ agentUsage,
204
+ dailyStats: trimmedStats,
205
+ firstSync: data.firstSync || getTimestamp(),
206
+ lastUpdated: getTimestamp(),
207
+ }
208
+ })
209
+ }
210
+
211
+ /**
212
+ * Get metrics summary for dashboard
213
+ */
214
+ async getSummary(projectId: string): Promise<{
215
+ totalTokensSaved: number
216
+ estimatedCostSaved: number
217
+ compressionRate: number
218
+ syncCount: number
219
+ avgSyncDuration: number
220
+ topAgents: AgentUsage[]
221
+ last30DaysTokens: number
222
+ trend: number // Percentage change vs previous 30 days
223
+ }> {
224
+ const data = await this.read(projectId)
225
+
226
+ const last30 = this.getLast30Days(data.dailyStats)
227
+ const prev30 = this.getPrev30Days(data.dailyStats)
228
+
229
+ const last30Tokens = last30.reduce((sum, d) => sum + d.tokensSaved, 0)
230
+ const prev30Tokens = prev30.reduce((sum, d) => sum + d.tokensSaved, 0)
231
+
232
+ const trend = prev30Tokens > 0
233
+ ? ((last30Tokens - prev30Tokens) / prev30Tokens) * 100
234
+ : 0
235
+
236
+ return {
237
+ totalTokensSaved: data.totalTokensSaved,
238
+ estimatedCostSaved: estimateCostSaved(data.totalTokensSaved),
239
+ compressionRate: data.avgCompressionRate,
240
+ syncCount: data.syncCount,
241
+ avgSyncDuration: data.avgSyncDuration,
242
+ topAgents: [...data.agentUsage].sort((a, b) => b.usageCount - a.usageCount).slice(0, 5),
243
+ last30DaysTokens: last30Tokens,
244
+ trend,
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Get daily stats for a period
250
+ */
251
+ async getDailyStats(projectId: string, days: number = 30): Promise<DailyStats[]> {
252
+ const data = await this.read(projectId)
253
+ const cutoff = new Date()
254
+ cutoff.setDate(cutoff.getDate() - days)
255
+ const cutoffStr = cutoff.toISOString().split('T')[0]
256
+
257
+ return data.dailyStats
258
+ .filter(d => d.date >= cutoffStr)
259
+ .sort((a, b) => a.date.localeCompare(b.date))
260
+ }
261
+
262
+ // =========== Helper Methods ===========
263
+
264
+ private getLast30Days(dailyStats: DailyStats[]): DailyStats[] {
265
+ const cutoff = new Date()
266
+ cutoff.setDate(cutoff.getDate() - 30)
267
+ const cutoffStr = cutoff.toISOString().split('T')[0]
268
+ return dailyStats.filter(d => d.date >= cutoffStr)
269
+ }
270
+
271
+ private getPrev30Days(dailyStats: DailyStats[]): DailyStats[] {
272
+ const end = new Date()
273
+ end.setDate(end.getDate() - 30)
274
+ const start = new Date()
275
+ start.setDate(start.getDate() - 60)
276
+
277
+ const startStr = start.toISOString().split('T')[0]
278
+ const endStr = end.toISOString().split('T')[0]
279
+
280
+ return dailyStats.filter(d => d.date >= startStr && d.date < endStr)
281
+ }
282
+
283
+ private formatTokens(tokens: number): string {
284
+ if (tokens >= 1_000_000) {
285
+ return `${(tokens / 1_000_000).toFixed(1)}M`
286
+ }
287
+ if (tokens >= 1_000) {
288
+ return `${(tokens / 1_000).toFixed(1)}K`
289
+ }
290
+ return tokens.toLocaleString()
291
+ }
292
+
293
+ private formatDuration(ms: number): string {
294
+ if (ms < 1000) {
295
+ return `${Math.round(ms)}ms`
296
+ }
297
+ return `${(ms / 1000).toFixed(1)}s`
298
+ }
299
+
300
+ private generateSparkline(dailyStats: DailyStats[]): string {
301
+ if (dailyStats.length === 0) return ''
302
+
303
+ const chars = '▁▂▃▄▅▆▇█'
304
+ const values = dailyStats.map(d => d.tokensSaved)
305
+ const max = Math.max(...values, 1)
306
+
307
+ return values.map(v => {
308
+ const idx = Math.min(Math.floor((v / max) * (chars.length - 1)), chars.length - 1)
309
+ return chars[idx]
310
+ }).join('')
311
+ }
312
+ }
313
+
314
+ export const metricsStorage = new MetricsStorage()
315
+ export default metricsStorage
@@ -294,6 +294,9 @@ export type {
294
294
  IdeaModule,
295
295
  IdeaRole,
296
296
  IdeasJson,
297
+ DailyStats,
298
+ AgentUsage,
299
+ MetricsJson,
297
300
  } from './storage'
298
301
 
299
302
  // =============================================================================
@@ -146,3 +146,52 @@ export interface IdeasJson {
146
146
  ideas: Idea[]
147
147
  lastUpdated: string
148
148
  }
149
+
150
+ // =============================================================================
151
+ // Metrics Storage Types
152
+ // =============================================================================
153
+
154
+ /**
155
+ * Daily stats for trend analysis
156
+ */
157
+ export interface DailyStats {
158
+ date: string // YYYY-MM-DD
159
+ tokensSaved: number // Tokens saved that day
160
+ syncs: number // Number of syncs
161
+ avgCompressionRate: number // Average compression rate (0-1)
162
+ totalDuration: number // Total sync time in ms
163
+ }
164
+
165
+ /**
166
+ * Agent usage tracking
167
+ */
168
+ export interface AgentUsage {
169
+ agentName: string // e.g., "backend", "frontend"
170
+ usageCount: number // Times invoked
171
+ tokensSaved: number // Tokens saved by this agent
172
+ }
173
+
174
+ /**
175
+ * Metrics collection for value dashboard
176
+ */
177
+ export interface MetricsJson {
178
+ // Token metrics
179
+ totalTokensSaved: number
180
+ avgCompressionRate: number // 0-1 (e.g., 0.63 = 63% reduction)
181
+
182
+ // Sync metrics
183
+ syncCount: number
184
+ watchTriggers: number // Auto-syncs from watch mode
185
+ avgSyncDuration: number // Average in ms
186
+ totalSyncDuration: number // Total in ms
187
+
188
+ // Agent usage
189
+ agentUsage: AgentUsage[]
190
+
191
+ // Time series for trends
192
+ dailyStats: DailyStats[]
193
+
194
+ // Metadata
195
+ firstSync: string // ISO8601 - when tracking started
196
+ lastUpdated: string // ISO8601
197
+ }