claude-matrix 1.0.3 → 1.0.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f3f9b8a20130e696e07a7cbeb49f63da290f3b28d48f81cac0733c71f141a6a1
4
- data.tar.gz: c05db374a55b0ce90d261a72f9eb09d148fbde729601484c1be1884d03df7130
3
+ metadata.gz: ac8dd503ff936a79b5f07dae6c531a47c06124e845bd2799b563f6264216f02c
4
+ data.tar.gz: 3d427f9e25d31ddcf16a4325ab5553f88624bfa17185b9e3a5a5722a9fb4f5a8
5
5
  SHA512:
6
- metadata.gz: d8ff3db3c0c1ec863067a7765913367c8880d173c2fb8fbc2957562acb323f30ba1124601fae4397271f32ed2264f05a70294f808b24afe9d4c71b6723017f6a
7
- data.tar.gz: 97ba05746729e2d5dc1c423bdd5401a59b649cb2cd14d1997b46110c357d02c4d06688b0f63201e4100b269fa4f86681aa6e3b6e17e7f414ec0d3c6400fde815
6
+ metadata.gz: c7a759af7385dbcde61f05d5a91032804a499b703e8479e4a5c3d8352f5bbca106aafd7d6dfbd7405a6efd1c09067ea3977bd11a4b8bf1cc32dab4e0d63dbc38
7
+ data.tar.gz: '0319c7e331e47c51d31500156c378dfb0014e358e259b7445b3af3cea15d0f211b8e44d4582ab106d0a62b11eaa0951771fa0cf7ec4be0f6e350104c0d6f26fb'
@@ -15,6 +15,16 @@ module ClaudeMatrix
15
15
 
16
16
  DEFAULT_PRICE = { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75 }.freeze
17
17
 
18
+ STOPWORDS = %w[
19
+ the a an and or but in on at to for of is it i my with this that you we
20
+ can be are was were have had do does did will would could should may might
21
+ must shall from by about as into through during before after above below
22
+ up down out off over under again further then once here there when where
23
+ why how all both each few more most other some such no nor not only own
24
+ same so than too very just because if while please let me make get how
25
+ what use new add your its im its just also want need its
26
+ ].freeze
27
+
18
28
  WORK_MODE_TOOLS = {
19
29
  exploration: %w[Read Glob Grep WebSearch WebFetch read_file list_files search_files grep],
20
30
  building: %w[Write Edit NotebookEdit write_to_file edit_file new_file],
@@ -42,6 +52,10 @@ module ClaudeMatrix
42
52
  projects = sessions.group_by { |s| s[:project] }
43
53
  most_active_project = projects.max_by { |_, ss| ss.sum { |s| s[:message_count] } }&.first
44
54
 
55
+ all_prompt_words = sessions.flat_map { |s| s[:prompt_words] || [] }
56
+ total_prompts = sessions.sum { |s| s[:prompt_count] || 0 }
57
+ avg_words = total_prompts > 0 ? (all_prompt_words.size.to_f / total_prompts).round : 0
58
+
45
59
  {
46
60
  total_sessions: sessions.size,
47
61
  total_messages: sessions.sum { |s| s[:message_count] },
@@ -59,6 +73,7 @@ module ClaudeMatrix
59
73
  top_tools: tool_counts.first(10).to_h,
60
74
  favorite_tool: tool_counts.first&.first,
61
75
  daily_activity: daily,
76
+ daily_cost: build_daily_cost(sessions),
62
77
  streaks: streaks,
63
78
  hour_counts: hour_counts,
64
79
  hour_day_grid: build_hour_day_grid(sessions),
@@ -68,6 +83,10 @@ module ClaudeMatrix
68
83
  most_productive_day: most_productive_day(daily),
69
84
  busiest_hour: hour_counts.max_by { |_, c| c }&.first,
70
85
  models_used: sessions.map { |s| s[:model] }.compact.uniq,
86
+ models_breakdown: build_models_breakdown(sessions),
87
+ total_prompts: total_prompts,
88
+ avg_prompt_words: avg_words,
89
+ top_prompt_words: top_prompt_words(all_prompt_words),
71
90
  per_project: build_per_project(sessions)
72
91
  }
73
92
  end
@@ -165,6 +184,15 @@ module ClaudeMatrix
165
184
  { date: date, sessions: data[:sessions], messages: data[:messages] }
166
185
  end
167
186
 
187
+ def self.build_daily_cost(sessions)
188
+ daily = Hash.new(0.0)
189
+ sessions.each do |s|
190
+ date = s[:started_at].to_date.to_s
191
+ daily[date] += estimate_cost([s])
192
+ end
193
+ daily.sort.to_h
194
+ end
195
+
168
196
  def self.build_hour_day_grid(sessions)
169
197
  # 7 rows (wday: 0=Sun..6=Sat) × 24 cols (hours)
170
198
  grid = Array.new(7) { Array.new(24, 0) }
@@ -186,6 +214,25 @@ module ClaudeMatrix
186
214
  end.sort_by { |_, v| -v[:sessions] }.to_h
187
215
  end
188
216
 
217
+ def self.build_models_breakdown(sessions)
218
+ models = sessions.map { |s| s[:model] }.compact
219
+ total = models.size.to_f
220
+ return {} if total.zero?
221
+
222
+ models.tally
223
+ .sort_by { |_, c| -c }
224
+ .to_h
225
+ .transform_values { |c| { count: c, pct: (c / total * 100).round } }
226
+ end
227
+
228
+ def self.top_prompt_words(words)
229
+ words.tally
230
+ .reject { |w, _| STOPWORDS.include?(w) || w.length < 3 || w.match?(/^\d+$/) }
231
+ .sort_by { |_, c| -c }
232
+ .first(6)
233
+ .to_h
234
+ end
235
+
189
236
  def self.empty_metrics
190
237
  {
191
238
  total_sessions: 0, total_messages: 0, total_tool_calls: 0,
@@ -194,11 +241,13 @@ module ClaudeMatrix
194
241
  estimated_cost: 0.0, first_session: nil, projects: 0,
195
242
  project_names: [], most_active_project: nil,
196
243
  tool_counts: {}, top_tools: {}, favorite_tool: nil,
197
- daily_activity: {}, streaks: { current: 0, longest: 0, active_days: 0 },
244
+ daily_activity: {}, daily_cost: {}, streaks: { current: 0, longest: 0, active_days: 0 },
198
245
  hour_counts: {}, work_modes: { exploration: 0, building: 0, testing: 0 },
199
246
  longest_session: nil, most_messages_session: nil,
200
247
  most_productive_day: nil, busiest_hour: nil,
201
- models_used: [], per_project: {}
248
+ models_used: [], models_breakdown: {},
249
+ total_prompts: 0, avg_prompt_words: 0, top_prompt_words: {},
250
+ per_project: {}
202
251
  }
203
252
  end
204
253
  end
@@ -49,6 +49,9 @@ module ClaudeMatrix
49
49
  session_id = nil
50
50
  cwd = nil
51
51
 
52
+ prompt_words = []
53
+ prompt_count = 0
54
+
52
55
  lines.each do |line|
53
56
  data = JSON.parse(line)
54
57
 
@@ -75,6 +78,22 @@ module ClaudeMatrix
75
78
  content.each do |block|
76
79
  tools << block["name"] if block["type"] == "tool_use" && block["name"]
77
80
  end
81
+
82
+ elsif data["type"] == "user"
83
+ # Extract prompt text (skip tool_result blocks — only human text)
84
+ content = data.dig("message", "content")
85
+ texts = case content
86
+ when Array then content.select { |b| b["type"] == "text" }.map { |b| b["text"].to_s }
87
+ when String then [content]
88
+ else []
89
+ end
90
+ text = texts.join(" ").strip
91
+ unless text.empty?
92
+ words = text.split(/\s+/).map { |w| w.downcase.gsub(/[^a-z0-9']/, "") }
93
+ .reject { |w| w.length < 2 }
94
+ prompt_words.concat(words)
95
+ prompt_count += 1
96
+ end
78
97
  end
79
98
  rescue JSON::ParserError
80
99
  next
@@ -101,7 +120,9 @@ module ClaudeMatrix
101
120
  input_tokens: input_tokens,
102
121
  output_tokens: output_tokens,
103
122
  cache_read: cache_read,
104
- cache_create: cache_create
123
+ cache_create: cache_create,
124
+ prompt_words: prompt_words,
125
+ prompt_count: prompt_count
105
126
  }
106
127
  rescue => e
107
128
  nil
@@ -1,3 +1,3 @@
1
1
  module ClaudeMatrix
2
- VERSION = "1.0.3"
2
+ VERSION = "1.0.4"
3
3
  end
@@ -199,46 +199,101 @@ module ClaudeMatrix
199
199
 
200
200
  def build_right
201
201
  rows = []
202
- rows += section("TOP TOOLS", @rw, tool_lines)
202
+ rows += tools_and_insights_section
203
203
  rows += section("TOKENS & COST", @rw, token_lines)
204
204
  rows += section("ACTIVITY (last 30 days)", @rw, activity_lines)
205
- rows += section("HEATMAP day × hour", @rw, heatmap_lines) if @show_heatmap
205
+ rows += heatmap_and_cost_section if @show_heatmap
206
206
  rows
207
207
  end
208
208
 
209
- # ── Section helper ────────────────────────────────────────────────────
209
+ # ── Tools + Insights (side by side) ──────────────────────────────────
210
210
 
211
- def section(title, w, lines)
212
- [divr,
213
- cr(sec_title(title, w), w)] + lines.map { |l| cr(l, w) }
214
- end
211
+ def tools_and_insights_section
212
+ @tools_w = [(@rw * 0.56).to_i, 26].max.clamp(26, 52)
213
+ @ins_w = @rw - @tools_w - 1
215
214
 
216
- def sec_title(title, w)
217
- fill = [w - vis(title) - 2, 0].max
218
- " #{bold(bright_cyan(title))} #{dim("─" * fill)}"
219
- end
215
+ t_lines = raw_tool_lines(@tools_w)
216
+ i_lines = raw_insights_lines(@ins_w)
217
+
218
+ n = [t_lines.size, i_lines.size, @n_tools].max
219
+ t_lines.fill(pad("", @tools_w), t_lines.size...n)
220
+ i_lines.fill(pad("", @ins_w), i_lines.size...n)
221
+
222
+ t_title = " #{bold(bright_cyan("TOP TOOLS"))} #{dim("─" * [@tools_w - 12, 0].max)}"
223
+ i_title = " #{bold(bright_cyan("MODELS & PROMPTS"))} #{dim("─" * [@ins_w - 19, 0].max)}"
224
+ title_row = cr(pad(t_title, @tools_w) + dim("│") + pad(i_title, @ins_w), @rw)
225
+
226
+ content = t_lines.zip(i_lines).map do |tl, il|
227
+ cr(pad(tl, @tools_w) + dim("│") + pad(il, @ins_w), @rw)
228
+ end
220
229
 
221
- # ── Tool bars ─────────────────────────────────────────────────────────
230
+ [divr, title_row] + content
231
+ end
222
232
 
223
- def tool_lines
233
+ def raw_tool_lines(w)
224
234
  tools = @m[:top_tools].to_a
225
235
  return [ind("No data yet")] if tools.empty?
226
236
 
227
237
  max_cnt = tools.first.last.to_f
228
238
  cnt_w = tools.first.last.to_s.length
229
- bar_w = [@rw - 20 - cnt_w, 4].max
239
+ bar_w = [w - 18 - cnt_w, 2].max
230
240
 
231
241
  tools.first(@n_tools).map do |tool, count|
232
242
  ratio = count.to_f / max_cnt
233
243
  bw = [(ratio * bar_w).round, 1].max
234
- # Color gradient: high→bright_cyan, mid→cyan, low→blue
235
244
  bar_col = ratio > 0.7 ? method(:bright_cyan) : ratio > 0.35 ? method(:cyan) : method(:blue)
236
245
  bar = bar_col.call("█" * bw) + dim("░" * [bar_w - bw, 0].max)
237
246
  cnt_s = bright_white(count.to_s.rjust(cnt_w))
238
- " #{dim(tool[0, 14].ljust(14))} #{bar} #{cnt_s}"
247
+ " #{dim(tool[0, 12].ljust(12))} #{bar} #{cnt_s}"
239
248
  end
240
249
  end
241
250
 
251
+ def raw_insights_lines(w)
252
+ lines = []
253
+ mb = @m[:models_breakdown] || {}
254
+ bar_w = [w - 20, 2].max
255
+ colors = [method(:bright_cyan), method(:cyan), method(:blue), method(:green)]
256
+
257
+ if mb.any?
258
+ mb.each_with_index do |(model, info), idx|
259
+ short = model.to_s.sub("claude-", "").sub(/-\d{8,}$/, "").sub(/-(\d+)-(\d+)$/, " \\1.\\2")
260
+ ratio = info[:pct] / 100.0
261
+ bw = [(ratio * bar_w).round, 1].max
262
+ col = colors[idx % colors.size]
263
+ bar = col.call("█" * bw) + dim("░" * [bar_w - bw, 0].max)
264
+ pct_s = col.call("#{info[:pct]}%".rjust(4))
265
+ lines << " #{dim(short[0, 12].ljust(12))} #{bar} #{pct_s}"
266
+ end
267
+ else
268
+ lines << " #{dim("No model data")}"
269
+ end
270
+
271
+ lines << " #{dim("─" * [w - 4, 2].max)}"
272
+
273
+ tp = @m[:total_prompts] || 0
274
+ ap = @m[:avg_prompt_words] || 0
275
+ lines << " #{dim("Prompts")} #{bright_cyan(tp.to_s)} #{dim("avg")} #{bright_white("#{ap} words")}"
276
+
277
+ tw = @m[:top_prompt_words] || {}
278
+ if tw.any?
279
+ lines << " #{dim("Top:")} #{bright_yellow(tw.keys.first(5).join(" · "))}"
280
+ end
281
+
282
+ lines
283
+ end
284
+
285
+ # ── Section helper ────────────────────────────────────────────────────
286
+
287
+ def section(title, w, lines)
288
+ [divr,
289
+ cr(sec_title(title, w), w)] + lines.map { |l| cr(l, w) }
290
+ end
291
+
292
+ def sec_title(title, w)
293
+ fill = [w - vis(title) - 2, 0].max
294
+ " #{bold(bright_cyan(title))} #{dim("─" * fill)}"
295
+ end
296
+
242
297
  # ── Token / Cost bars ─────────────────────────────────────────────────
243
298
 
244
299
  def token_lines
@@ -308,21 +363,83 @@ module ClaudeMatrix
308
363
  lines
309
364
  end
310
365
 
311
- # ── Heatmap ───────────────────────────────────────────────────────────
366
+ # ── Heatmap + Daily Cost (side by side) ──────────────────────────────
367
+
368
+ def heatmap_and_cost_section
369
+ # Split the right column: heatmap left, cost right
370
+ @hmap_w = [(@rw * 0.54).to_i, 33].max.clamp(33, 50)
371
+ @cost_w = @rw - @hmap_w - 1 # -1 for inner separator
372
+
373
+ h_lines = raw_heatmap_lines
374
+ c_lines = raw_cost_lines
312
375
 
313
- def heatmap_lines
376
+ # Align heights
377
+ n = [h_lines.size, c_lines.size].max
378
+ h_lines.fill(pad("", @hmap_w), h_lines.size...n)
379
+ c_lines.fill(pad("", @cost_w), c_lines.size...n)
380
+
381
+ # Build split title row
382
+ h_title = " #{bold(bright_cyan("HEATMAP"))} #{dim("─" * [@hmap_w - 10, 0].max)}"
383
+ c_title = " #{bold(bright_cyan("DAILY COST"))} #{dim("─" * [@cost_w - 13, 0].max)}"
384
+ title_row = cr(pad(h_title, @hmap_w) + dim("│") + pad(c_title, @cost_w), @rw)
385
+
386
+ content = h_lines.zip(c_lines).map do |hl, cl|
387
+ cr(pad(hl, @hmap_w) + dim("│") + pad(cl, @cost_w), @rw)
388
+ end
389
+
390
+ [divr, title_row] + content
391
+ end
392
+
393
+ def raw_heatmap_lines
314
394
  grid = @m[:hour_day_grid]
315
395
  max_val = [grid.flatten.max, 1].max.to_f
316
- step = 3
317
- hlabels = (0..21).step(step).map { |h| h.to_s.rjust(3) }.join
318
- lines = [" #{dim(" #{hlabels}")}"]
396
+
397
+ # Choose step to fill @hmap_w: prefix "Mon " = 5 + 1 indent, each cell = 1 + sep chars
398
+ inner = @hmap_w - 9 # available for cells after label + indent
399
+ step = inner >= 46 ? 1 : inner >= 23 ? 2 : 3
400
+ hours = (0...24).step(step).to_a
401
+ sep = " " * step
402
+
403
+ # cells string is exactly: n cells + (n-1) separators
404
+ cells_vis = hours.size + (hours.size - 1) * step
405
+
406
+ # Build header: place key-hour labels at their exact cell positions
407
+ hlabel_arr = Array.new(cells_vis, " ")
408
+ [0, 6, 12, 18].each do |h|
409
+ idx_in_hours = hours.index(h); next unless idx_in_hours
410
+ pos = idx_in_hours * (1 + step)
411
+ h.to_s.chars.each_with_index { |c, li| hlabel_arr[pos + li] = c if pos + li < cells_vis }
412
+ end
413
+ hlabels = hlabel_arr.join
414
+ lines = [" #{dim(" #{hlabels}")}"]
415
+
319
416
  DAY_WDAY.each_with_index do |wday, i|
320
- cells = (0..21).step(step).map { |h| heat_block(grid[wday][h], max_val) }.join(" ")
321
- lines << " #{cyan(DAY_NAMES[i])} #{cells}"
417
+ cells = hours.map { |h| heat_block(grid[wday][h], max_val) }.join(sep)
418
+ lines << " #{cyan(DAY_NAMES[i])} #{cells}"
322
419
  end
323
420
  lines
324
421
  end
325
422
 
423
+ def raw_cost_lines
424
+ daily_cost = @m[:daily_cost]
425
+ return [" #{dim("No cost data")}"] if daily_cost.empty?
426
+
427
+ max_cost = [daily_cost.values.max, 0.01].max.to_f
428
+ # Layout: " MMM DD bar $XXX.XX"
429
+ # overhead: 1(indent) + 6(label) + 2(gap) + 1(space) = 10, amount up to 7 → bar_w = @cost_w - 17
430
+ bar_w = [@cost_w - 17, 2].max
431
+ n_days = [daily_cost.size, 14].min
432
+
433
+ daily_cost.to_a.last(n_days).reverse.map do |date, cost|
434
+ label = Date.parse(date).strftime("%b %-d").rjust(6)
435
+ ratio = cost / max_cost
436
+ bw = [(ratio * bar_w).round, cost > 0 ? 1 : 0].max
437
+ bar = cost_color(cost, "█" * bw) + dim("░" * [bar_w - bw, 0].max)
438
+ amount = cost_color(cost, "$#{"%.2f" % cost}")
439
+ " #{dim(label)} #{bar} #{amount}"
440
+ end
441
+ end
442
+
326
443
  def heat_block(count, max)
327
444
  r = count.to_f / max
328
445
  if r == 0 then dim("░")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: claude-matrix
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.3
4
+ version: 1.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kapil Bhosale