token-lens 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5df4aa9d712d04ae90513777b2b34b541bb3b77038fadd637725f1bf1599db3f
4
- data.tar.gz: 52f773f6c7bfd677a29bef81ba14bb23e2b01ec54071468ee6ba7be2e4632b9a
3
+ metadata.gz: 4e268e0550d46641067f1f99507b60d92251a4b58d025b39b8b3f90c428eb47a
4
+ data.tar.gz: 03243e21fd60fa99443b35a9e384fbb84d1fcf7c00072c297fa5fb0d46ae83a7
5
5
  SHA512:
6
- metadata.gz: 0b271f96c07b9c74db5ca42e4ffff7a7259e5a63893e949a9ab0fc3dd2bc4761df0c783151769a42cd320dc6e5574ae408a5b2dbfe2133cc839bab60d12f638a
7
- data.tar.gz: 8f296614fd7f957189e07961223fea18a2242fee5f090860a2b49d6f69daf90f92dadc87a289ce4b22cbaf739d0d25cfdfbd2c2cac062fe4ed7e23f952db156c
6
+ metadata.gz: 88af830c547a6ef86aa5b0fdf763822df9a9ca134b5d304769ba66f97585c731012c90d9498a599bac64abdace620fbec9f113b9c9e476acf301c4a18dfe7bc0
7
+ data.tar.gz: d4c8be308c772f5c117d79a5b82d4939b5f2f45776be127f3ec6bc5bb73487055be5c29b92c30b12e848fad89f6e4f9b387ab648ae055a27e282639b66fec42a
data/README.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Token Lens
2
2
 
3
+ [![Gem Version](https://img.shields.io/gem/v/token-lens)](https://rubygems.org/gems/token-lens)
4
+ [![Gem Downloads](https://img.shields.io/gem/dt/token-lens)](https://rubygems.org/gems/token-lens)
5
+ [![CI](https://github.com/Brickell-Research/token-lens/actions/workflows/ci.yml/badge.svg)](https://github.com/Brickell-Research/token-lens/actions/workflows/ci.yml)
6
+ [![Ruby >= 3.2](https://img.shields.io/badge/Ruby-%3E%3D3.2-red?logo=ruby&logoColor=white)](https://www.ruby-lang.org/)
7
+ [![Standard](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://github.com/standardrb/standard)
8
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
9
+
3
10
  Basically a combination of [perf](https://perfwiki.github.io/main/) plus [flame-graphs](https://www.brendangregg.com/flamegraphs.html) for local [claude-code](https://code.claude.com/docs/en/overview) usage.
4
11
 
5
12
  ## Architecture
@@ -16,19 +23,44 @@ Record Data --> Interpret Data --> Render Data
16
23
 
17
24
  ## Quick Start
18
25
 
26
+ ### Zero setup — render any session right now
27
+
28
+ No recording needed. token-lens reads directly from Claude Code's session files:
19
29
 
20
- **Locally**:
21
30
  ```
22
- bin/token-lens record --duration-in-seconds=30 > capture.json
23
- bin/token-lens render --file-path=capture.json
31
+ gem install token-lens
32
+ token-lens render
24
33
  open flame.html
25
34
  ```
26
35
 
27
- **Via gem**:
36
+ That's it. `render` with no arguments finds your most recent Claude Code session and renders it as a flame graph — no prior setup, no capture file, no extra terminal.
37
+
38
+ ### Record a live session
39
+
40
+ If you want to capture a bounded window while you work (useful for comparing before/after):
41
+
28
42
  ```
29
- gem install token-lens
30
- token-lens record --duration-in-seconds=30 > capture.json
31
- token-lens render --file-path=capture.json
43
+ token-lens record --duration-in-seconds=60
44
+ # ... do your Claude Code work in another terminal ...
45
+ token-lens render
46
+ open flame.html
47
+ ```
48
+
49
+ Captures auto-save to `~/.token-lens/sessions/<timestamp>.json`. `render` always picks the most recent capture first, then falls back to the live session JSONL if no captures exist.
50
+
51
+ ### Options
52
+
53
+ ```
54
+ token-lens record --duration-in-seconds=300 # record for 5 minutes
55
+ token-lens record --output=my-session.json # save to a specific path
56
+ token-lens render --file-path=my-session.json # render a specific capture
57
+ token-lens render --output=report.html # write HTML to a custom path
58
+ ```
59
+
60
+ ### Locally
61
+
62
+ ```
63
+ bin/token-lens render
32
64
  open flame.html
33
65
  ```
34
66
 
@@ -6,18 +6,20 @@ require "token_lens/commands/render"
6
6
 
7
7
  module TokenLens
8
8
  class CLI < Thor
9
- desc "record", "Tail the active session and capture events to stdout"
9
+ desc "record", "Tail the active session and auto-save a capture file"
10
10
  option :duration_in_seconds, type: :numeric, default: 30, desc: "Seconds to record"
11
11
  option :project_dir, type: :string, desc: "Working directory of the Claude Code session to record (default: auto-detect)"
12
+ option :output, type: :string, desc: "Save path for the capture (default: ~/.token-lens/sessions/<timestamp>.json)"
12
13
  def record
13
14
  Commands::Record.new(
14
15
  duration_in_seconds: options[:duration_in_seconds],
15
- project_dir: options[:project_dir]
16
+ project_dir: options[:project_dir],
17
+ output: options[:output]
16
18
  ).run
17
19
  end
18
20
 
19
21
  desc "render", "Render a captured session as a flame graph"
20
- option :file_path, type: :string, required: true, desc: "Path to the captured JSON file"
22
+ option :file_path, type: :string, desc: "Path to the captured JSON file (default: most recent session)"
21
23
  option :output, type: :string, default: "flame.html", desc: "Output HTML path"
22
24
  def render
23
25
  Commands::Render.new(file_path: options[:file_path], output: options[:output]).run
@@ -1,14 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "fileutils"
4
5
  require "token_lens/sources/jsonl"
5
6
 
6
7
  module TokenLens
7
8
  module Commands
8
9
  class Record
9
- def initialize(duration_in_seconds:, project_dir: nil)
10
+ SESSIONS_DIR = Pathname.new(Dir.home).join(".token-lens", "sessions")
11
+
12
+ def initialize(duration_in_seconds:, project_dir: nil, output: nil)
10
13
  @duration_in_seconds = duration_in_seconds
11
14
  @project_dir = project_dir
15
+ @output = output
12
16
  end
13
17
 
14
18
  def run
@@ -34,9 +38,18 @@ module TokenLens
34
38
  drain_thread.kill
35
39
  events << queue.pop until queue.empty?
36
40
  warn "\nCaptured #{events.size} events"
37
- $stdout.puts JSON.generate(events)
41
+ path = save_path
42
+ FileUtils.mkdir_p(path.dirname)
43
+ path.write(JSON.generate(events))
44
+ warn "Saved to #{path}"
38
45
  exit 0
39
46
  end
47
+
48
+ def save_path
49
+ return Pathname.new(@output) if @output
50
+ timestamp = Time.now.strftime("%Y-%m-%d_%H-%M-%S")
51
+ SESSIONS_DIR.join("#{timestamp}.json")
52
+ end
40
53
  end
41
54
  end
42
55
  end
@@ -5,17 +5,20 @@ require "token_lens/renderer/reshaper"
5
5
  require "token_lens/renderer/annotator"
6
6
  require "token_lens/renderer/layout"
7
7
  require "token_lens/renderer/html"
8
+ require "token_lens/session"
8
9
 
9
10
  module TokenLens
10
11
  module Commands
11
12
  class Render
12
- def initialize(file_path:, output:)
13
+ def initialize(output:, file_path: nil)
13
14
  @file_path = file_path
14
15
  @output = output
15
16
  end
16
17
 
17
18
  def run
18
- tree = Parser.new(file_path: @file_path).parse
19
+ path = resolve_path
20
+ warn "Rendering #{path}"
21
+ tree = Parser.new(file_path: path).parse
19
22
  tree = Renderer::Reshaper.new.reshape(tree)
20
23
  Renderer::Annotator.new.annotate(tree)
21
24
  Renderer::Layout.new.layout(tree)
@@ -23,6 +26,17 @@ module TokenLens
23
26
  File.write(@output, html)
24
27
  warn "Wrote #{@output}"
25
28
  end
29
+
30
+ private
31
+
32
+ def resolve_path
33
+ return @file_path if @file_path
34
+ sessions = Pathname.new(Dir.home).join(".token-lens", "sessions")
35
+ saved = sessions.glob("*.json").max_by(&:mtime)
36
+ return saved if saved
37
+ warn "No saved captures found — reading active Claude Code session directly"
38
+ Session.latest_jsonl
39
+ end
26
40
  end
27
41
  end
28
42
  end
@@ -12,7 +12,7 @@ module TokenLens
12
12
  end
13
13
 
14
14
  def parse
15
- raw_events = JSON.parse(read_file).map { |e| e["event"] }
15
+ raw_events = read_raw_events
16
16
  tokens = raw_events
17
17
  .map { |e| Tokens::Jsonl.from_raw(e) }
18
18
  .select { |t| t.type == "user" || t.type == "assistant" }
@@ -162,6 +162,25 @@ module TokenLens
162
162
  nodes.flat_map { |n| [n, *flatten_nodes(n[:children])] }
163
163
  end
164
164
 
165
+ def read_raw_events
166
+ content = read_file
167
+ if content.lstrip.start_with?("[")
168
+ # Captured format: JSON array of {"event": {...}} wrappers produced by `record`
169
+ JSON.parse(content).map { |e| e["event"] }
170
+ else
171
+ # Raw JSONL: newline-delimited events from ~/.claude/projects/*/...jsonl
172
+ content.each_line.filter_map { |line|
173
+ begin
174
+ JSON.parse(line)
175
+ rescue
176
+ nil
177
+ end
178
+ }
179
+ end
180
+ rescue => e
181
+ raise TokenLens::ParseError, "Failed to parse #{@file_path}: #{e.message}"
182
+ end
183
+
165
184
  def read_file
166
185
  File.read(@file_path)
167
186
  rescue => e
@@ -5,7 +5,17 @@ module TokenLens
5
5
  class Reshaper
6
6
  def reshape(nodes)
7
7
  nodes = collapse_streaming(nodes)
8
- nodes.flat_map { |node| process_root(node) }
8
+ # Process roots iteratively so that human prompts discovered mid-thread
9
+ # (nested inside an assistant chain) are hoisted to the top level rather
10
+ # than stacked as children of the prompt that preceded them.
11
+ @pending_roots = nodes.dup
12
+ result = []
13
+ while @pending_roots.any?
14
+ batch = @pending_roots
15
+ @pending_roots = []
16
+ result += batch.flat_map { |node| process_root(node) }
17
+ end
18
+ result
9
19
  end
10
20
 
11
21
  private
@@ -58,11 +68,17 @@ module TokenLens
58
68
  # Flatten a linear user→assistant→user(tool_result)→assistant chain into
59
69
  # a flat list of assistant siblings, computing marginal_input_tokens deltas.
60
70
  # Sidechain children stay nested under the assistant that spawned them.
61
- def flatten_thread(nodes, prev_input:)
71
+ #
72
+ # through_assistant: tracks whether we've passed at least one assistant turn.
73
+ # A human prompt encountered BEFORE any assistant (e.g. a screenshot attached
74
+ # to the same user turn) is treated as transparent — we recurse into its
75
+ # children rather than hoisting it. A human prompt encountered AFTER an
76
+ # assistant is a genuine new conversational turn and gets hoisted.
77
+ def flatten_thread(nodes, prev_input:, through_assistant: false)
62
78
  nodes.flat_map do |node|
63
79
  t = node[:token]
64
80
  if t.role == "user" && !t.is_human_prompt?
65
- flatten_thread(node[:children], prev_input: prev_input)
81
+ flatten_thread(node[:children], prev_input: prev_input, through_assistant: through_assistant)
66
82
  elsif t.role == "assistant"
67
83
  marginal = [t.input_tokens - prev_input, 0].max
68
84
  compaction = prev_input > 0 && t.input_tokens < prev_input * 0.5
@@ -77,9 +93,15 @@ module TokenLens
77
93
  token: t.with(marginal_input_tokens: marginal, is_compaction: compaction),
78
94
  children: sidechain
79
95
  )
80
- [updated] + flatten_thread(chain, prev_input: t.input_tokens)
96
+ [updated] + flatten_thread(chain, prev_input: t.input_tokens, through_assistant: true)
97
+ elsif through_assistant
98
+ # Human prompt after an assistant — genuine new turn, hoist to top level.
99
+ @pending_roots << node
100
+ []
81
101
  else
82
- process_root(node)
102
+ # Human prompt before any assistant (consecutive user messages, e.g. an
103
+ # image attachment). Treat as transparent and continue into its children.
104
+ flatten_thread(node[:children], prev_input: prev_input, through_assistant: false)
83
105
  end
84
106
  end
85
107
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TokenLens
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: token-lens
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rob durst