token-lens 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: fc58978bdc3f4759309ded99d65da22be12a01287ea79acdb43ab873e7cb8771
4
+ data.tar.gz: 34c54363d13e72ed669e3f27bbf1fc8af2d3665e958d977baabfaac9ab80f849
5
+ SHA512:
6
+ metadata.gz: 3f3c35dedc0cd0efd6e12b8d136aab7f4328ba4619f912f07c94727e3bd6c4c2bb4184d07a9e3bbb117dd0eaeec28edd27c5f7100f140a79b12043773a066881
7
+ data.tar.gz: d0767d26c77b4ba6dfec6f3a40b70c7d1dde3b3a58094fa8707b4bf57bc860612ed1af91c161b31fc3f3faa78f3db25ed6f5ddc3ae2f9422ca351084e3d5ec41
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brickell Research
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # Token Lens
2
+
3
+ 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
+
5
+ ## Architecture
6
+
7
+ ```
8
+ Record Data --> Interpret Data --> Render Data
9
+ ```
10
+
11
+ ## Data Sources
12
+
13
+ 1. Tap OTEL console output
14
+ 2. Tail session JSONL
15
+ 3. Local API proxy
16
+
17
+ ## Contributing
18
+
19
+ Checkout [CONTRIBUTING.md](./CONTRIBUTING.md).
20
+
21
+ ## License
22
+
23
+ [MIT](./LICENSE)
data/bin/token-lens ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "rubygems"
5
+ require "bundler/setup"
6
+ require "token_lens/cli"
7
+ TokenLens::CLI.start(ARGV)
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require "token_lens/commands/record"
5
+ require "token_lens/commands/render"
6
+
7
+ module TokenLens
8
+ class CLI < Thor
9
+ desc "record", "Tail the active session and capture events to stdout"
10
+ option :duration_in_seconds, type: :numeric, default: 30, desc: "Seconds to record"
11
+ def record
12
+ Commands::Record.new(duration_in_seconds: options[:duration_in_seconds]).run
13
+ end
14
+
15
+ 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"
17
+ option :output, type: :string, default: "flame.html", desc: "Output HTML path"
18
+ def render
19
+ Commands::Render.new(file_path: options[:file_path], output: options[:output]).run
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "token_lens/sources/jsonl"
5
+
6
+ module TokenLens
7
+ module Commands
8
+ class Record
9
+ def initialize(duration_in_seconds:)
10
+ @duration_in_seconds = duration_in_seconds
11
+ end
12
+
13
+ def run
14
+ warn "Recording for #{@duration_in_seconds}s... (Ctrl+C to stop early)"
15
+
16
+ queue = Queue.new
17
+ events = []
18
+
19
+ thread = Thread.new { Sources::Jsonl.new(queue).start }
20
+ drain_thread = Thread.new { loop { events << queue.pop } }
21
+
22
+ trap("INT") { finish(thread, drain_thread, queue, events) }
23
+ trap("TERM") { finish(thread, drain_thread, queue, events) }
24
+
25
+ sleep @duration_in_seconds
26
+ finish(thread, drain_thread, queue, events)
27
+ end
28
+
29
+ private
30
+
31
+ def finish(thread, drain_thread, queue, events)
32
+ thread.kill
33
+ drain_thread.kill
34
+ events << queue.pop until queue.empty?
35
+ warn "\nCaptured #{events.size} events"
36
+ $stdout.puts JSON.generate(events)
37
+ exit 0
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "token_lens/parser"
4
+ require "token_lens/renderer/reshaper"
5
+ require "token_lens/renderer/annotator"
6
+ require "token_lens/renderer/layout"
7
+ require "token_lens/renderer/html"
8
+
9
+ module TokenLens
10
+ module Commands
11
+ class Render
12
+ def initialize(file_path:, output:)
13
+ @file_path = file_path
14
+ @output = output
15
+ end
16
+
17
+ def run
18
+ tree = Parser.new(file_path: @file_path).parse
19
+ tree = Renderer::Reshaper.new.reshape(tree)
20
+ Renderer::Annotator.new.annotate(tree)
21
+ Renderer::Layout.new.layout(tree)
22
+ html = Renderer::Html.new.render(tree)
23
+ File.write(@output, html)
24
+ warn "Wrote #{@output}"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "token_lens/tokens/jsonl"
5
+
6
+ module TokenLens
7
+ class ParseError < StandardError; end
8
+
9
+ class Parser
10
+ def initialize(file_path:)
11
+ @file_path = file_path
12
+ end
13
+
14
+ def parse
15
+ raw_events = JSON.parse(read_file).map { |e| e["event"] }
16
+ tokens = raw_events
17
+ .map { |e| Tokens::Jsonl.from_raw(e) }
18
+ .select { |t| t.type == "user" || t.type == "assistant" }
19
+ tree = build_tree(tokens, raw_events)
20
+ attach_subagent_turns(tree, raw_events)
21
+ attach_task_notifications(tree)
22
+ tree
23
+ end
24
+
25
+ private
26
+
27
+ def build_tree(tokens, raw_events = [])
28
+ index = tokens.each_with_object({}) { |t, h| h[t.uuid] = {token: t, children: []} }
29
+
30
+ # Build a parent map covering ALL raw events (including filtered progress/tool-result
31
+ # events) so we can walk through gaps when a token's direct parent was filtered out.
32
+ raw_parent = {}
33
+ raw_events.each { |e| raw_parent[e["uuid"]] = e["parentUuid"] if e["uuid"] }
34
+
35
+ roots = []
36
+ index.each_value do |node|
37
+ parent_uuid = node[:token].parent_uuid
38
+ # Walk up through filtered events to find the nearest indexed ancestor.
39
+ hops = 0
40
+ while parent_uuid && !index[parent_uuid] && hops < 20
41
+ parent_uuid = raw_parent[parent_uuid]
42
+ hops += 1
43
+ end
44
+ parent = parent_uuid && index[parent_uuid]
45
+ parent ? parent[:children] << node : roots << node
46
+ end
47
+
48
+ roots
49
+ end
50
+
51
+ # Extract subagent turns from agent_progress events and attach them as
52
+ # is_sidechain children of the assistant turn that invoked the Agent tool.
53
+ def attach_subagent_turns(tree, raw_events)
54
+ progress_by_tool_use = Hash.new { |h, k| h[k] = [] }
55
+ raw_events.each do |evt|
56
+ next unless evt["type"] == "progress"
57
+ next unless evt.dig("data", "type") == "agent_progress"
58
+ next unless evt.dig("data", "message", "type") == "assistant"
59
+ tool_use_id = evt["parentToolUseID"]
60
+ next unless tool_use_id
61
+ progress_by_tool_use[tool_use_id] << evt
62
+ end
63
+
64
+ return if progress_by_tool_use.empty?
65
+
66
+ # Index all nodes by their tool_use content IDs
67
+ tool_use_node = {}
68
+ flatten_nodes(tree).each do |node|
69
+ node[:token].tool_uses.each { |tu| tool_use_node[tu["id"]] = node }
70
+ end
71
+
72
+ progress_by_tool_use.each do |tool_use_id, evts|
73
+ parent = tool_use_node[tool_use_id]
74
+ next unless parent
75
+ parent[:children] += build_subagent_nodes(evts)
76
+ end
77
+ end
78
+
79
+ # Collapse streaming chains (same requestId = one API call) and build tokens.
80
+ def build_subagent_nodes(events)
81
+ by_request = Hash.new { |h, k| h[k] = [] }
82
+ events.each do |evt|
83
+ req_id = evt.dig("data", "message", "requestId") || evt["uuid"]
84
+ by_request[req_id] << evt
85
+ end
86
+
87
+ by_request.values
88
+ .sort_by { |g| g.first["timestamp"] || "" }
89
+ .map { |group| subagent_token(group) }
90
+ .map { |t| {token: t, children: []} }
91
+ end
92
+
93
+ def subagent_token(group)
94
+ representative = group.first
95
+ msg_data = representative.dig("data", "message")
96
+ inner = msg_data["message"] || {}
97
+ usage = inner["usage"] || {}
98
+
99
+ # Combine tool_uses across streaming events in this API call (parallel tools)
100
+ combined_tool_uses = group.flat_map { |evt|
101
+ Array(evt.dig("data", "message", "message", "content"))
102
+ .select { |b| b.is_a?(Hash) && b["type"] == "tool_use" }
103
+ }.uniq { |tu| tu["id"] }
104
+
105
+ content = combined_tool_uses.empty? ? Array(inner["content"]) : combined_tool_uses
106
+
107
+ Tokens::Jsonl.new(
108
+ uuid: msg_data["uuid"] || representative["uuid"],
109
+ parent_uuid: nil,
110
+ request_id: msg_data["requestId"],
111
+ type: "assistant",
112
+ role: "assistant",
113
+ model: inner["model"],
114
+ is_sidechain: true,
115
+ agent_id: representative.dig("data", "agentId"),
116
+ content: content,
117
+ input_tokens: usage["input_tokens"].to_i,
118
+ output_tokens: usage["output_tokens"].to_i,
119
+ cache_read_tokens: usage["cache_read_input_tokens"].to_i,
120
+ cache_creation_tokens: usage["cache_creation_input_tokens"].to_i,
121
+ marginal_input_tokens: 0,
122
+ timestamp: representative["timestamp"],
123
+ is_compaction: false
124
+ )
125
+ end
126
+
127
+ # Wire task-notification user turns back to the Agent call that spawned them.
128
+ # Each <task-notification> contains a <tool-use-id> that matches an Agent
129
+ # tool call in the main thread. We detach the notification from its current
130
+ # tree position, mark it is_sidechain, and attach it under the Agent call node.
131
+ def attach_task_notifications(tree)
132
+ all = flatten_nodes(tree)
133
+
134
+ agent_node_by_tool_use = {}
135
+ all.each do |node|
136
+ node[:token].tool_uses.each do |tu|
137
+ next unless tu["name"] == "Agent"
138
+ agent_node_by_tool_use[tu["id"]] = node
139
+ end
140
+ end
141
+ return if agent_node_by_tool_use.empty?
142
+
143
+ all.select { |n| n[:token].is_task_notification? }.each do |node|
144
+ tool_use_id = node[:token].human_text
145
+ .match(/<tool-use-id>\s*(.*?)\s*<\/tool-use-id>/m)&.[](1)
146
+ next unless tool_use_id
147
+ agent_node = agent_node_by_tool_use[tool_use_id]
148
+ next unless agent_node
149
+ next unless remove_node(tree, node)
150
+
151
+ node[:token] = node[:token].with(is_sidechain: true)
152
+ agent_node[:children] << node
153
+ end
154
+ end
155
+
156
+ def remove_node(nodes, target)
157
+ return true if nodes.delete(target)
158
+ nodes.any? { |node| remove_node(node[:children], target) }
159
+ end
160
+
161
+ def flatten_nodes(nodes)
162
+ nodes.flat_map { |n| [n, *flatten_nodes(n[:children])] }
163
+ end
164
+
165
+ def read_file
166
+ File.read(@file_path)
167
+ rescue => e
168
+ raise TokenLens::ParseError, "Failed to read file: #{e.message}"
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenLens
4
+ module Pricing
5
+ # Prices in USD per million tokens. Source: platform.claude.com/docs/en/about-claude/pricing
6
+ # Last verified: 2026-03-23
7
+ #
8
+ # cache_creation = 5-minute cache write (1.25x input). The API reports this as
9
+ # cache_creation_input_tokens in the usage object.
10
+ # cache_read = cache hit (0.1x input).
11
+ #
12
+ # Entries are matched via String#start_with? in order — put more specific prefixes first.
13
+ TABLE = {
14
+ # --- Opus 4.5 / 4.6 (new pricing tier: $5/$25) ---
15
+ "claude-opus-4-6" => {input: 5.0, cache_read: 0.50, cache_creation: 6.25, output: 25.0},
16
+ "claude-opus-4-5" => {input: 5.0, cache_read: 0.50, cache_creation: 6.25, output: 25.0},
17
+
18
+ # --- Opus 4.0 / 4.1 (original tier: $15/$75) ---
19
+ "claude-opus-4" => {input: 15.0, cache_read: 1.50, cache_creation: 18.75, output: 75.0},
20
+
21
+ # --- Sonnet 4.x (all variants same price) ---
22
+ "claude-sonnet-4" => {input: 3.0, cache_read: 0.30, cache_creation: 3.75, output: 15.0},
23
+
24
+ # --- Haiku 4.5 ---
25
+ "claude-haiku-4-5" => {input: 1.0, cache_read: 0.10, cache_creation: 1.25, output: 5.0},
26
+
27
+ # --- Haiku 4.x fallback ---
28
+ "claude-haiku-4" => {input: 1.0, cache_read: 0.10, cache_creation: 1.25, output: 5.0},
29
+
30
+ # --- Claude 3.x (legacy, new-style IDs like claude-sonnet-3-7) ---
31
+ "claude-sonnet-3" => {input: 3.0, cache_read: 0.30, cache_creation: 3.75, output: 15.0},
32
+ "claude-haiku-3-5" => {input: 0.80, cache_read: 0.08, cache_creation: 1.00, output: 4.0},
33
+
34
+ # --- Claude 3.x (old-style IDs like claude-3-opus-20240229) ---
35
+ "claude-3-opus" => {input: 15.0, cache_read: 1.50, cache_creation: 18.75, output: 75.0},
36
+ "claude-3-5-sonnet" => {input: 3.0, cache_read: 0.30, cache_creation: 3.75, output: 15.0},
37
+ "claude-3-sonnet" => {input: 3.0, cache_read: 0.30, cache_creation: 3.75, output: 15.0},
38
+ "claude-3-5-haiku" => {input: 0.80, cache_read: 0.08, cache_creation: 1.00, output: 4.0},
39
+ "claude-3-haiku" => {input: 0.25, cache_read: 0.03, cache_creation: 0.30, output: 1.25}
40
+ }.freeze
41
+
42
+ # Fallback when model string is nil or unrecognised — use Sonnet 4 rates
43
+ FALLBACK = {input: 3.0, cache_read: 0.30, cache_creation: 3.75, output: 15.0}.freeze
44
+
45
+ def self.for_model(model)
46
+ return FALLBACK unless model
47
+ _prefix, rates = TABLE.find { |prefix, _| model.start_with?(prefix) }
48
+ rates || FALLBACK
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TokenLens
4
+ module Renderer
5
+ class Annotator
6
+ def annotate(nodes, depth = 0)
7
+ nodes.each do |node|
8
+ node[:depth] = depth
9
+ annotate(node[:children], depth + 1)
10
+ child_tokens = node[:children].sum { |c| c[:subtree_tokens] }
11
+ child_cost = node[:children].sum { |c| c[:subtree_cost] }
12
+ node[:subtree_tokens] = [node[:token].display_width, 1].max + child_tokens
13
+ node[:subtree_cost] = node[:token].cost_usd + child_cost
14
+ end
15
+ nodes
16
+ end
17
+ end
18
+ end
19
+ end