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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ac8dd503ff936a79b5f07dae6c531a47c06124e845bd2799b563f6264216f02c
|
|
4
|
+
data.tar.gz: 3d427f9e25d31ddcf16a4325ab5553f88624bfa17185b9e3a5a5722a9fb4f5a8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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: [],
|
|
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
|
|
@@ -199,46 +199,101 @@ module ClaudeMatrix
|
|
|
199
199
|
|
|
200
200
|
def build_right
|
|
201
201
|
rows = []
|
|
202
|
-
rows +=
|
|
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 +=
|
|
205
|
+
rows += heatmap_and_cost_section if @show_heatmap
|
|
206
206
|
rows
|
|
207
207
|
end
|
|
208
208
|
|
|
209
|
-
# ──
|
|
209
|
+
# ── Tools + Insights (side by side) ──────────────────────────────────
|
|
210
210
|
|
|
211
|
-
def
|
|
212
|
-
[
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
230
|
+
[divr, title_row] + content
|
|
231
|
+
end
|
|
222
232
|
|
|
223
|
-
def
|
|
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 = [
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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 =
|
|
321
|
-
lines << "
|
|
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("░")
|