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 +4 -4
- data/README.md +39 -7
- data/lib/token_lens/cli.rb +5 -3
- data/lib/token_lens/commands/record.rb +15 -2
- data/lib/token_lens/commands/render.rb +16 -2
- data/lib/token_lens/parser.rb +20 -1
- data/lib/token_lens/renderer/reshaper.rb +27 -5
- data/lib/token_lens/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e268e0550d46641067f1f99507b60d92251a4b58d025b39b8b3f90c428eb47a
|
|
4
|
+
data.tar.gz: 03243e21fd60fa99443b35a9e384fbb84d1fcf7c00072c297fa5fb0d46ae83a7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 88af830c547a6ef86aa5b0fdf763822df9a9ca134b5d304769ba66f97585c731012c90d9498a599bac64abdace620fbec9f113b9c9e476acf301c4a18dfe7bc0
|
|
7
|
+
data.tar.gz: d4c8be308c772f5c117d79a5b82d4939b5f2f45776be127f3ec6bc5bb73487055be5c29b92c30b12e848fad89f6e4f9b387ab648ae055a27e282639b66fec42a
|
data/README.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Token Lens
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/token-lens)
|
|
4
|
+
[](https://rubygems.org/gems/token-lens)
|
|
5
|
+
[](https://github.com/Brickell-Research/token-lens/actions/workflows/ci.yml)
|
|
6
|
+
[](https://www.ruby-lang.org/)
|
|
7
|
+
[](https://github.com/standardrb/standard)
|
|
8
|
+
[](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
|
-
|
|
23
|
-
|
|
31
|
+
gem install token-lens
|
|
32
|
+
token-lens render
|
|
24
33
|
open flame.html
|
|
25
34
|
```
|
|
26
35
|
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
token-lens render
|
|
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
|
|
data/lib/token_lens/cli.rb
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
data/lib/token_lens/parser.rb
CHANGED
|
@@ -12,7 +12,7 @@ module TokenLens
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def parse
|
|
15
|
-
raw_events =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/token_lens/version.rb
CHANGED