token-lens 0.1.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: fc58978bdc3f4759309ded99d65da22be12a01287ea79acdb43ab873e7cb8771
4
- data.tar.gz: 34c54363d13e72ed669e3f27bbf1fc8af2d3665e958d977baabfaac9ab80f849
3
+ metadata.gz: 4e268e0550d46641067f1f99507b60d92251a4b58d025b39b8b3f90c428eb47a
4
+ data.tar.gz: 03243e21fd60fa99443b35a9e384fbb84d1fcf7c00072c297fa5fb0d46ae83a7
5
5
  SHA512:
6
- metadata.gz: 3f3c35dedc0cd0efd6e12b8d136aab7f4328ba4619f912f07c94727e3bd6c4c2bb4184d07a9e3bbb117dd0eaeec28edd27c5f7100f140a79b12043773a066881
7
- data.tar.gz: d0767d26c77b4ba6dfec6f3a40b70c7d1dde3b3a58094fa8707b4bf57bc860612ed1af91c161b31fc3f3faa78f3db25ed6f5ddc3ae2f9422ca351084e3d5ec41
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
@@ -10,9 +17,52 @@ Record Data --> Interpret Data --> Render Data
10
17
 
11
18
  ## Data Sources
12
19
 
13
- 1. Tap OTEL console output
14
- 2. Tail session JSONL
15
- 3. Local API proxy
20
+ 1. [unsupported] Tap OTEL console output
21
+ 2. [supported] Tail session JSONL
22
+ 3. [unsupported] Local API proxy
23
+
24
+ ## Quick Start
25
+
26
+ ### Zero setup — render any session right now
27
+
28
+ No recording needed. token-lens reads directly from Claude Code's session files:
29
+
30
+ ```
31
+ gem install token-lens
32
+ token-lens render
33
+ open flame.html
34
+ ```
35
+
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
+
42
+ ```
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
64
+ open flame.html
65
+ ```
16
66
 
17
67
  ## Contributing
18
68
 
@@ -6,14 +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
+ 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)"
11
13
  def record
12
- Commands::Record.new(duration_in_seconds: options[:duration_in_seconds]).run
14
+ Commands::Record.new(
15
+ duration_in_seconds: options[:duration_in_seconds],
16
+ project_dir: options[:project_dir],
17
+ output: options[:output]
18
+ ).run
13
19
  end
14
20
 
15
21
  desc "render", "Render a captured session as a flame graph"
16
- 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)"
17
23
  option :output, type: :string, default: "flame.html", desc: "Output HTML path"
18
24
  def render
19
25
  Commands::Render.new(file_path: options[:file_path], output: options[:output]).run
@@ -1,13 +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:)
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
14
+ @project_dir = project_dir
15
+ @output = output
11
16
  end
12
17
 
13
18
  def run
@@ -16,7 +21,7 @@ module TokenLens
16
21
  queue = Queue.new
17
22
  events = []
18
23
 
19
- thread = Thread.new { Sources::Jsonl.new(queue).start }
24
+ thread = Thread.new { Sources::Jsonl.new(queue, project_dir: @project_dir).start }
20
25
  drain_thread = Thread.new { loop { events << queue.pop } }
21
26
 
22
27
  trap("INT") { finish(thread, drain_thread, queue, events) }
@@ -33,9 +38,18 @@ module TokenLens
33
38
  drain_thread.kill
34
39
  events << queue.pop until queue.empty?
35
40
  warn "\nCaptured #{events.size} events"
36
- $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}"
37
45
  exit 0
38
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
39
53
  end
40
54
  end
41
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
@@ -16,15 +16,28 @@ module TokenLens
16
16
 
17
17
  def self.active_jsonl(dir = Dir.pwd)
18
18
  project_dir = CLAUDE_DIR / encoded_cwd(dir)
19
-
20
- # find all *.jsonl files in the project directory
21
19
  jsonl_files = project_dir.glob("*.jsonl")
22
20
  raise "No session files found in #{project_dir}" if jsonl_files.empty?
23
-
24
- # get most recently modified file
25
21
  jsonl_files.max_by(&:mtime)
26
22
  end
27
23
 
24
+ # Returns the most recently modified session file across ALL projects.
25
+ def self.latest_jsonl
26
+ all = CLAUDE_DIR.glob("*/*.jsonl")
27
+ raise "No session files found in #{CLAUDE_DIR}" if all.empty?
28
+ all.max_by(&:mtime)
29
+ end
30
+
31
+ # Like active_jsonl but falls back to latest_jsonl with a warning when
32
+ # the current directory has no sessions (e.g. running from ~/Desktop).
33
+ def self.active_or_latest_jsonl(dir = Dir.pwd)
34
+ active_jsonl(dir)
35
+ rescue RuntimeError
36
+ path = latest_jsonl
37
+ warn " [session] no sessions for #{dir}, using most recent: #{path}"
38
+ path
39
+ end
40
+
28
41
  def self.tail(path, &block)
29
42
  last_pos = File.size(path)
30
43
  loop do
@@ -6,9 +6,9 @@ require "token_lens/session"
6
6
  module TokenLens
7
7
  module Sources
8
8
  class Jsonl
9
- def initialize(queue)
9
+ def initialize(queue, project_dir: nil)
10
10
  @queue = queue
11
- @path = Session.active_jsonl
11
+ @path = project_dir ? Session.active_jsonl(project_dir) : Session.active_or_latest_jsonl
12
12
  end
13
13
 
14
14
  def start
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module TokenLens
4
- VERSION = "0.1.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.1.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - rob durst