rubyn-code 0.2.2 → 0.3.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 +91 -3
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +55 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +149 -0
- data/lib/rubyn_code/agent/loop.rb +175 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
- data/lib/rubyn_code/agent/tool_processor.rb +158 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +31 -44
- data/lib/rubyn_code/autonomous/daemon.rb +29 -18
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
- data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +128 -114
- data/lib/rubyn_code/cli/commands/model.rb +75 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +42 -373
- data/lib/rubyn_code/cli/repl_commands.rb +176 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
- data/lib/rubyn_code/cli/repl_setup.rb +145 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +10 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/settings.rb +100 -1
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +167 -0
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +7 -5
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/index/codebase_index.rb +245 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +55 -252
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +9 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +26 -32
- data/lib/rubyn_code/tools/bash.rb +2 -1
- data/lib/rubyn_code/tools/edit_file.rb +74 -18
- data/lib/rubyn_code/tools/executor.rb +74 -24
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +23 -7
- data/lib/rubyn_code/tools/grep.rb +2 -1
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +185 -0
- data/lib/rubyn_code/tools/read_file.rb +11 -6
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +59 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +40 -1
- data/skills/rubyn_self_test.md +121 -0
- metadata +53 -1
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Observability
|
|
8
|
+
# Tracks per-skill usage and ROI metrics. Records when skills are loaded,
|
|
9
|
+
# how long they stay in context, whether suggestions from them are accepted,
|
|
10
|
+
# and their token cost. Enables monthly pruning of low-usage skills.
|
|
11
|
+
class SkillAnalytics
|
|
12
|
+
TABLE_NAME = 'skill_usage'
|
|
13
|
+
|
|
14
|
+
Entry = Data.define(
|
|
15
|
+
:skill_name, :loaded_at_turn, :last_referenced_turn,
|
|
16
|
+
:tokens_cost, :accepted, :session_id
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
attr_reader :entries
|
|
20
|
+
|
|
21
|
+
def initialize(db: nil)
|
|
22
|
+
@db = db
|
|
23
|
+
@entries = []
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Record a skill usage event.
|
|
27
|
+
def record(skill_name:, loaded_at_turn:, last_referenced_turn: nil, tokens_cost: 0, accepted: nil)
|
|
28
|
+
entry = Entry.new(
|
|
29
|
+
skill_name: skill_name.to_s,
|
|
30
|
+
loaded_at_turn: loaded_at_turn,
|
|
31
|
+
last_referenced_turn: last_referenced_turn || loaded_at_turn,
|
|
32
|
+
tokens_cost: tokens_cost.to_i,
|
|
33
|
+
accepted: accepted,
|
|
34
|
+
session_id: nil
|
|
35
|
+
)
|
|
36
|
+
@entries << entry
|
|
37
|
+
persist(entry) if @db
|
|
38
|
+
entry
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Calculate usage statistics across all recorded entries.
|
|
42
|
+
def usage_stats
|
|
43
|
+
return {} if @entries.empty?
|
|
44
|
+
|
|
45
|
+
by_skill = @entries.group_by(&:skill_name)
|
|
46
|
+
by_skill.transform_values do |entries|
|
|
47
|
+
{
|
|
48
|
+
load_count: entries.size,
|
|
49
|
+
total_tokens: entries.sum(&:tokens_cost),
|
|
50
|
+
avg_tokens: (entries.sum(&:tokens_cost).to_f / entries.size).round(0),
|
|
51
|
+
acceptance_rate: acceptance_rate(entries),
|
|
52
|
+
avg_lifespan: avg_lifespan(entries)
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Returns skills with usage rate below threshold (candidates for pruning).
|
|
58
|
+
def low_usage_skills(threshold: 0.05)
|
|
59
|
+
stats = usage_stats
|
|
60
|
+
total = @entries.size.to_f
|
|
61
|
+
return [] if total.zero?
|
|
62
|
+
|
|
63
|
+
stats.select do |_, s|
|
|
64
|
+
(s[:load_count] / total) < threshold
|
|
65
|
+
end.keys
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Returns skills sorted by ROI (accepted suggestions per token spent).
|
|
69
|
+
def roi_ranking
|
|
70
|
+
stats = usage_stats
|
|
71
|
+
stats.sort_by do |_, s|
|
|
72
|
+
tokens = s[:total_tokens]
|
|
73
|
+
rate = s[:acceptance_rate] || 0
|
|
74
|
+
tokens.positive? ? -(rate / tokens) : 0
|
|
75
|
+
end.map(&:first)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Format a report for the /cost command.
|
|
79
|
+
def report
|
|
80
|
+
stats = usage_stats
|
|
81
|
+
return 'No skill usage data.' if stats.empty?
|
|
82
|
+
|
|
83
|
+
lines = ['Skill Usage:']
|
|
84
|
+
stats.each do |name, s|
|
|
85
|
+
lines << " #{name}: #{s[:load_count]}x loaded, #{s[:total_tokens]} tokens"
|
|
86
|
+
end
|
|
87
|
+
lines.join("\n")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def acceptance_rate(entries)
|
|
93
|
+
rated = entries.reject { |e| e.accepted.nil? }
|
|
94
|
+
return nil if rated.empty?
|
|
95
|
+
|
|
96
|
+
(rated.count(&:accepted).to_f / rated.size).round(3)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def avg_lifespan(entries)
|
|
100
|
+
spans = entries.map { |e| e.last_referenced_turn - e.loaded_at_turn }
|
|
101
|
+
(spans.sum.to_f / spans.size).round(1)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def persist(entry)
|
|
105
|
+
@db.execute(
|
|
106
|
+
"INSERT INTO #{TABLE_NAME} (skill_name, loaded_at_turn, last_referenced_turn, " \
|
|
107
|
+
'tokens_cost, accepted, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
|
108
|
+
[entry.skill_name, entry.loaded_at_turn, entry.last_referenced_turn,
|
|
109
|
+
entry.tokens_cost, entry.accepted ? 1 : 0, Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')]
|
|
110
|
+
)
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
RubynCode::Debug.warn("SkillAnalytics: #{e.message}")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Observability
|
|
5
|
+
# Tracks detailed token usage breakdown by category (system prompt,
|
|
6
|
+
# skills, context files, conversation, tool output) and reports
|
|
7
|
+
# savings from efficiency features. Powers the enhanced /cost command.
|
|
8
|
+
class TokenAnalytics
|
|
9
|
+
CHARS_PER_TOKEN = 4
|
|
10
|
+
|
|
11
|
+
CATEGORIES = %i[
|
|
12
|
+
system_prompt skills_loaded context_files
|
|
13
|
+
conversation tool_output code_written
|
|
14
|
+
explanations tool_calls
|
|
15
|
+
].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :input_breakdown, :output_breakdown, :savings
|
|
18
|
+
|
|
19
|
+
def initialize
|
|
20
|
+
@input_breakdown = Hash.new(0)
|
|
21
|
+
@output_breakdown = Hash.new(0)
|
|
22
|
+
@savings = Hash.new(0)
|
|
23
|
+
@start_time = Time.now
|
|
24
|
+
@turn_count = 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Record input token usage by category.
|
|
28
|
+
def record_input(category, tokens)
|
|
29
|
+
@input_breakdown[category.to_sym] += tokens.to_i
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Record output token usage by category.
|
|
33
|
+
def record_output(category, tokens)
|
|
34
|
+
@output_breakdown[category.to_sym] += tokens.to_i
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Record tokens saved by an efficiency feature.
|
|
38
|
+
def record_savings(feature, tokens)
|
|
39
|
+
@savings[feature.to_sym] += tokens.to_i
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Increment the turn counter.
|
|
43
|
+
def record_turn!
|
|
44
|
+
@turn_count += 1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Total input tokens across all categories.
|
|
48
|
+
def total_input_tokens
|
|
49
|
+
@input_breakdown.values.sum
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Total output tokens across all categories.
|
|
53
|
+
def total_output_tokens
|
|
54
|
+
@output_breakdown.values.sum
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Total tokens saved across all features.
|
|
58
|
+
def total_tokens_saved
|
|
59
|
+
@savings.values.sum
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Session duration in minutes.
|
|
63
|
+
def session_minutes
|
|
64
|
+
((Time.now - @start_time) / 60.0).round(1)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Format a complete analytics report.
|
|
68
|
+
def report(**)
|
|
69
|
+
lines = [header]
|
|
70
|
+
lines.concat(input_section)
|
|
71
|
+
lines << ''
|
|
72
|
+
lines.concat(output_section)
|
|
73
|
+
lines << ''
|
|
74
|
+
lines.concat(savings_section) if @savings.any?
|
|
75
|
+
lines.join("\n")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def header
|
|
81
|
+
duration = session_minutes
|
|
82
|
+
"Session: #{duration} min | #{@turn_count} turns"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def input_section
|
|
86
|
+
total = total_input_tokens
|
|
87
|
+
lines = ['Input tokens:'.rjust(20) + " #{fmt(total)}"]
|
|
88
|
+
|
|
89
|
+
@input_breakdown.each do |cat, tokens|
|
|
90
|
+
pct = total.positive? ? ((tokens.to_f / total) * 100).round(0) : 0
|
|
91
|
+
lines << (" #{humanize(cat)}:".ljust(22) + "#{fmt(tokens).rjust(8)} (#{pct}%)")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
lines
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def output_section
|
|
98
|
+
total = total_output_tokens
|
|
99
|
+
lines = ['Output tokens:'.rjust(20) + " #{fmt(total)}"]
|
|
100
|
+
|
|
101
|
+
@output_breakdown.each do |cat, tokens|
|
|
102
|
+
pct = total.positive? ? ((tokens.to_f / total) * 100).round(0) : 0
|
|
103
|
+
lines << (" #{humanize(cat)}:".ljust(22) + "#{fmt(tokens).rjust(8)} (#{pct}%)")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
lines
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def savings_section
|
|
110
|
+
total = total_tokens_saved
|
|
111
|
+
lines = ['Savings applied:']
|
|
112
|
+
|
|
113
|
+
@savings.each do |feature, tokens|
|
|
114
|
+
lines << (" #{humanize(feature)}:".ljust(22) + "-#{fmt(tokens)} tokens saved")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
lines << (' Total saved:'.ljust(22) + "-#{fmt(total)} tokens")
|
|
118
|
+
lines
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def humanize(sym)
|
|
122
|
+
sym.to_s.tr('_', ' ').capitalize
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def fmt(num)
|
|
126
|
+
num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -27,24 +27,8 @@ module RubynCode
|
|
|
27
27
|
|
|
28
28
|
return "No usage data for session #{session_id}." if rows.empty?
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
total_cost = rows.sum { |r| fetch_float(r, 'cost_usd') }
|
|
33
|
-
turns = rows.size
|
|
34
|
-
avg_cost = turns.positive? ? total_cost / turns : 0.0
|
|
35
|
-
|
|
36
|
-
lines = [
|
|
37
|
-
header('Session Summary'),
|
|
38
|
-
field('Session', session_id),
|
|
39
|
-
field('Turns', turns.to_s),
|
|
40
|
-
field('Input tokens', format_number(total_input)),
|
|
41
|
-
field('Output tokens', format_number(total_output)),
|
|
42
|
-
field('Total tokens', format_number(total_input + total_output)),
|
|
43
|
-
field('Total cost', format_usd(total_cost)),
|
|
44
|
-
field('Avg cost/turn', format_usd(avg_cost))
|
|
45
|
-
]
|
|
46
|
-
|
|
47
|
-
lines.join("\n")
|
|
30
|
+
totals = compute_session_totals(rows)
|
|
31
|
+
build_session_summary_lines(session_id, rows.size, totals).join("\n")
|
|
48
32
|
end
|
|
49
33
|
|
|
50
34
|
# Returns a formatted summary of today's total cost across all sessions.
|
|
@@ -52,31 +36,11 @@ module RubynCode
|
|
|
52
36
|
# @return [String] multi-line formatted summary
|
|
53
37
|
def daily_summary
|
|
54
38
|
today = Time.now.utc.strftime('%Y-%m-%d')
|
|
55
|
-
rows =
|
|
56
|
-
'SELECT session_id, SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens, ' \
|
|
57
|
-
"SUM(cost_usd) AS cost_usd, COUNT(*) AS turns FROM #{TABLE_NAME} " \
|
|
58
|
-
'WHERE created_at >= ? GROUP BY session_id',
|
|
59
|
-
["#{today}T00:00:00Z"]
|
|
60
|
-
).to_a
|
|
39
|
+
rows = query_daily_rows(today)
|
|
61
40
|
|
|
62
41
|
return 'No usage data for today.' if rows.empty?
|
|
63
42
|
|
|
64
|
-
|
|
65
|
-
total_output = rows.sum { |r| fetch_int(r, 'output_tokens') }
|
|
66
|
-
total_cost = rows.sum { |r| fetch_float(r, 'cost_usd') }
|
|
67
|
-
total_turns = rows.sum { |r| fetch_int(r, 'turns') }
|
|
68
|
-
sessions = rows.size
|
|
69
|
-
|
|
70
|
-
lines = [
|
|
71
|
-
header("Daily Summary (#{today})"),
|
|
72
|
-
field('Sessions', sessions.to_s),
|
|
73
|
-
field('Total turns', total_turns.to_s),
|
|
74
|
-
field('Input tokens', format_number(total_input)),
|
|
75
|
-
field('Output tokens', format_number(total_output)),
|
|
76
|
-
field('Total cost', format_usd(total_cost))
|
|
77
|
-
]
|
|
78
|
-
|
|
79
|
-
lines.join("\n")
|
|
43
|
+
build_daily_summary_lines(today, rows).join("\n")
|
|
80
44
|
end
|
|
81
45
|
|
|
82
46
|
# Returns a cost breakdown by model for a given session.
|
|
@@ -84,28 +48,12 @@ module RubynCode
|
|
|
84
48
|
# @param session_id [String]
|
|
85
49
|
# @return [String] multi-line formatted breakdown
|
|
86
50
|
def model_breakdown(session_id)
|
|
87
|
-
rows =
|
|
88
|
-
'SELECT model, SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens, ' \
|
|
89
|
-
"SUM(cost_usd) AS cost_usd, COUNT(*) AS calls FROM #{TABLE_NAME} " \
|
|
90
|
-
'WHERE session_id = ? GROUP BY model ORDER BY cost_usd DESC',
|
|
91
|
-
[session_id]
|
|
92
|
-
).to_a
|
|
51
|
+
rows = query_model_breakdown_rows(session_id)
|
|
93
52
|
|
|
94
53
|
return "No usage data for session #{session_id}." if rows.empty?
|
|
95
54
|
|
|
96
55
|
lines = [header('Cost by Model')]
|
|
97
|
-
|
|
98
|
-
rows.each do |row|
|
|
99
|
-
model = row['model'] || row[:model]
|
|
100
|
-
cost = fetch_float(row, 'cost_usd')
|
|
101
|
-
calls = fetch_int(row, 'calls')
|
|
102
|
-
input_t = fetch_int(row, 'input_tokens')
|
|
103
|
-
output_t = fetch_int(row, 'output_tokens')
|
|
104
|
-
|
|
105
|
-
lines << " #{@formatter.pastel.bold(model)}"
|
|
106
|
-
lines << " Calls: #{calls} | Input: #{format_number(input_t)} | Output: #{format_number(output_t)} | Cost: #{format_usd(cost)}"
|
|
107
|
-
end
|
|
108
|
-
|
|
56
|
+
rows.each { |row| append_model_row(lines, row) }
|
|
109
57
|
lines.join("\n")
|
|
110
58
|
end
|
|
111
59
|
|
|
@@ -121,11 +69,81 @@ module RubynCode
|
|
|
121
69
|
end
|
|
122
70
|
|
|
123
71
|
def format_usd(amount)
|
|
124
|
-
'$%.4f'
|
|
72
|
+
format('$%.4f', amount)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def compute_session_totals(rows)
|
|
76
|
+
{
|
|
77
|
+
input: rows.sum { |r| fetch_int(r, 'input_tokens') },
|
|
78
|
+
output: rows.sum { |r| fetch_int(r, 'output_tokens') },
|
|
79
|
+
cost: rows.sum { |r| fetch_float(r, 'cost_usd') }
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_session_summary_lines(session_id, turns, totals) # rubocop:disable Metrics/AbcSize -- assembles multi-field summary
|
|
84
|
+
avg_cost = turns.positive? ? totals[:cost] / turns : 0.0
|
|
85
|
+
[
|
|
86
|
+
header('Session Summary'),
|
|
87
|
+
field('Session', session_id),
|
|
88
|
+
field('Turns', turns.to_s),
|
|
89
|
+
field('Input tokens', format_number(totals[:input])),
|
|
90
|
+
field('Output tokens', format_number(totals[:output])),
|
|
91
|
+
field('Total tokens', format_number(totals[:input] + totals[:output])),
|
|
92
|
+
field('Total cost', format_usd(totals[:cost])),
|
|
93
|
+
field('Avg cost/turn', format_usd(avg_cost))
|
|
94
|
+
]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def query_daily_rows(today)
|
|
98
|
+
@db.query(
|
|
99
|
+
'SELECT session_id, SUM(input_tokens) AS input_tokens, ' \
|
|
100
|
+
'SUM(output_tokens) AS output_tokens, SUM(cost_usd) AS cost_usd, ' \
|
|
101
|
+
"COUNT(*) AS turns FROM #{TABLE_NAME} " \
|
|
102
|
+
'WHERE created_at >= ? GROUP BY session_id',
|
|
103
|
+
["#{today}T00:00:00Z"]
|
|
104
|
+
).to_a
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def build_daily_summary_lines(today, rows) # rubocop:disable Metrics/AbcSize -- assembles multi-field daily summary
|
|
108
|
+
total_input = rows.sum { |r| fetch_int(r, 'input_tokens') }
|
|
109
|
+
total_output = rows.sum { |r| fetch_int(r, 'output_tokens') }
|
|
110
|
+
total_cost = rows.sum { |r| fetch_float(r, 'cost_usd') }
|
|
111
|
+
total_turns = rows.sum { |r| fetch_int(r, 'turns') }
|
|
112
|
+
|
|
113
|
+
[
|
|
114
|
+
header("Daily Summary (#{today})"),
|
|
115
|
+
field('Sessions', rows.size.to_s),
|
|
116
|
+
field('Total turns', total_turns.to_s),
|
|
117
|
+
field('Input tokens', format_number(total_input)),
|
|
118
|
+
field('Output tokens', format_number(total_output)),
|
|
119
|
+
field('Total cost', format_usd(total_cost))
|
|
120
|
+
]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def query_model_breakdown_rows(session_id)
|
|
124
|
+
@db.query(
|
|
125
|
+
'SELECT model, SUM(input_tokens) AS input_tokens, ' \
|
|
126
|
+
'SUM(output_tokens) AS output_tokens, SUM(cost_usd) AS cost_usd, ' \
|
|
127
|
+
"COUNT(*) AS calls FROM #{TABLE_NAME} " \
|
|
128
|
+
'WHERE session_id = ? GROUP BY model ORDER BY cost_usd DESC',
|
|
129
|
+
[session_id]
|
|
130
|
+
).to_a
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def append_model_row(lines, row)
|
|
134
|
+
model = row['model'] || row[:model]
|
|
135
|
+
lines << " #{@formatter.pastel.bold(model)}"
|
|
136
|
+
lines << format(
|
|
137
|
+
' Calls: %<calls>s | Input: %<input>s | Output: %<output>s | Cost: %<cost>s',
|
|
138
|
+
calls: fetch_int(row, 'calls'),
|
|
139
|
+
input: format_number(fetch_int(row, 'input_tokens')),
|
|
140
|
+
output: format_number(fetch_int(row, 'output_tokens')),
|
|
141
|
+
cost: format_usd(fetch_float(row, 'cost_usd'))
|
|
142
|
+
)
|
|
125
143
|
end
|
|
126
144
|
|
|
127
|
-
def format_number(
|
|
128
|
-
|
|
145
|
+
def format_number(num)
|
|
146
|
+
num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
129
147
|
end
|
|
130
148
|
|
|
131
149
|
def fetch_int(row, key)
|
|
@@ -37,18 +37,20 @@ module RubynCode
|
|
|
37
37
|
hunks = compute_hunks(old_lines, new_lines)
|
|
38
38
|
return pastel.dim('No differences found.') if hunks.empty?
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
parts << render_header(filename)
|
|
42
|
-
hunks.each { |hunk| parts << render_hunk(hunk) }
|
|
43
|
-
parts << ''
|
|
44
|
-
|
|
45
|
-
result = parts.join("\n")
|
|
40
|
+
result = assemble_output(hunks, filename)
|
|
46
41
|
$stdout.puts(result)
|
|
47
42
|
result
|
|
48
43
|
end
|
|
49
44
|
|
|
50
45
|
private
|
|
51
46
|
|
|
47
|
+
def assemble_output(hunks, filename)
|
|
48
|
+
parts = [render_header(filename)]
|
|
49
|
+
hunks.each { |hunk| parts << render_hunk(hunk) }
|
|
50
|
+
parts << ''
|
|
51
|
+
parts.join("\n")
|
|
52
|
+
end
|
|
53
|
+
|
|
52
54
|
def render_header(filename)
|
|
53
55
|
[
|
|
54
56
|
pastel.bold("--- a/#{filename}"),
|
|
@@ -84,54 +86,68 @@ module RubynCode
|
|
|
84
86
|
|
|
85
87
|
# Builds the LCS length table for two arrays of lines.
|
|
86
88
|
def build_lcs_table(old_lines, new_lines)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
table = Array.new(
|
|
90
|
-
|
|
91
|
-
(1..
|
|
92
|
-
(
|
|
93
|
-
table[i][j] = if old_lines[i - 1] == new_lines[j - 1]
|
|
94
|
-
table[i - 1][j - 1] + 1
|
|
95
|
-
else
|
|
96
|
-
[table[i - 1][j], table[i][j - 1]].max
|
|
97
|
-
end
|
|
98
|
-
end
|
|
89
|
+
row_count = old_lines.size
|
|
90
|
+
col_count = new_lines.size
|
|
91
|
+
table = Array.new(row_count + 1) { Array.new(col_count + 1, 0) }
|
|
92
|
+
|
|
93
|
+
(1..row_count).each do |row|
|
|
94
|
+
fill_lcs_row(table, row, old_lines, new_lines, col_count)
|
|
99
95
|
end
|
|
100
96
|
|
|
101
97
|
table
|
|
102
98
|
end
|
|
103
99
|
|
|
100
|
+
def fill_lcs_row(table, row, old_lines, new_lines, col_count) # rubocop:disable Metrics/AbcSize -- LCS algorithm step
|
|
101
|
+
(1..col_count).each do |col|
|
|
102
|
+
table[row][col] = if old_lines[row - 1] == new_lines[col - 1]
|
|
103
|
+
table[row - 1][col - 1] + 1
|
|
104
|
+
else
|
|
105
|
+
[table[row - 1][col], table[row][col - 1]].max
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
104
110
|
# Backtracks through the LCS table to produce a sequence of diff operations.
|
|
105
111
|
# Returns an array of [:equal, :delete, :add] paired with line indices.
|
|
106
112
|
def backtrack_diff(table, old_lines, new_lines)
|
|
107
113
|
result = []
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
while
|
|
112
|
-
|
|
113
|
-
result.unshift([:equal, i - 1, j - 1])
|
|
114
|
-
i -= 1
|
|
115
|
-
j -= 1
|
|
116
|
-
elsif j.positive? && (i.zero? || table[i][j - 1] >= table[i - 1][j])
|
|
117
|
-
result.unshift([:add, nil, j - 1])
|
|
118
|
-
j -= 1
|
|
119
|
-
elsif i.positive?
|
|
120
|
-
result.unshift([:delete, i - 1, nil])
|
|
121
|
-
i -= 1
|
|
122
|
-
end
|
|
114
|
+
old_idx = old_lines.size
|
|
115
|
+
new_idx = new_lines.size
|
|
116
|
+
|
|
117
|
+
while old_idx.positive? || new_idx.positive?
|
|
118
|
+
old_idx, new_idx = backtrack_step(result, table, old_lines, new_lines, old_idx, new_idx)
|
|
123
119
|
end
|
|
124
120
|
|
|
125
121
|
result
|
|
126
122
|
end
|
|
127
123
|
|
|
124
|
+
def backtrack_step(result, table, old_lines, new_lines, old_idx, new_idx) # rubocop:disable Metrics/AbcSize, Metrics/ParameterLists -- LCS backtrack step requires all state
|
|
125
|
+
if lines_match?(old_lines, new_lines, old_idx, new_idx)
|
|
126
|
+
result.unshift([:equal, old_idx - 1, new_idx - 1])
|
|
127
|
+
[old_idx - 1, new_idx - 1]
|
|
128
|
+
elsif new_idx.positive? && (old_idx.zero? || table[old_idx][new_idx - 1] >= table[old_idx - 1][new_idx])
|
|
129
|
+
result.unshift([:add, nil, new_idx - 1])
|
|
130
|
+
[old_idx, new_idx - 1]
|
|
131
|
+
else
|
|
132
|
+
result.unshift([:delete, old_idx - 1, nil])
|
|
133
|
+
[old_idx - 1, new_idx]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def lines_match?(old_lines, new_lines, old_idx, new_idx)
|
|
138
|
+
old_idx.positive? && new_idx.positive? && old_lines[old_idx - 1] == new_lines[new_idx - 1]
|
|
139
|
+
end
|
|
140
|
+
|
|
128
141
|
# Groups raw diff operations into hunks with surrounding context lines.
|
|
129
142
|
def group_into_hunks(raw_diff, old_lines, new_lines)
|
|
130
|
-
# Identify change indices (non-equal operations)
|
|
131
143
|
change_indices = raw_diff.each_index.reject { |idx| raw_diff[idx][0] == :equal }
|
|
132
144
|
return [] if change_indices.empty?
|
|
133
145
|
|
|
134
|
-
|
|
146
|
+
groups = cluster_changes(change_indices)
|
|
147
|
+
groups.map { |group| build_hunk(group, raw_diff, old_lines, new_lines) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def cluster_changes(change_indices)
|
|
135
151
|
groups = []
|
|
136
152
|
current_group = [change_indices.first]
|
|
137
153
|
|
|
@@ -144,54 +160,63 @@ module RubynCode
|
|
|
144
160
|
end
|
|
145
161
|
end
|
|
146
162
|
groups << current_group
|
|
163
|
+
end
|
|
147
164
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
old_count
|
|
157
|
-
new_count
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
old_start ||= old_idx + 1
|
|
165
|
-
new_start ||= new_idx + 1
|
|
166
|
-
lines << DiffLine.new(type: :context, content: old_lines[old_idx])
|
|
167
|
-
old_count += 1
|
|
168
|
-
new_count += 1
|
|
169
|
-
when :delete
|
|
170
|
-
old_start ||= old_idx + 1
|
|
171
|
-
new_start ||= (new_idx || find_new_start(raw_diff, idx)) + 1
|
|
172
|
-
lines << DiffLine.new(type: :delete, content: old_lines[old_idx])
|
|
173
|
-
old_count += 1
|
|
174
|
-
when :add
|
|
175
|
-
old_start ||= (old_idx || find_old_start(raw_diff, idx)) + 1
|
|
176
|
-
new_start ||= new_idx + 1
|
|
177
|
-
lines << DiffLine.new(type: :add, content: new_lines[new_idx])
|
|
178
|
-
new_count += 1
|
|
179
|
-
end
|
|
180
|
-
end
|
|
165
|
+
def build_hunk(group, raw_diff, old_lines, new_lines)
|
|
166
|
+
range_start = [group.first - @context_lines, 0].max
|
|
167
|
+
range_end = [group.last + @context_lines, raw_diff.size - 1].min
|
|
168
|
+
|
|
169
|
+
lines, old_start, new_start, old_count, new_count =
|
|
170
|
+
collect_hunk_lines(range_start, range_end, raw_diff, old_lines, new_lines)
|
|
171
|
+
|
|
172
|
+
Hunk.new(
|
|
173
|
+
old_start: old_start || 1, old_count: old_count,
|
|
174
|
+
new_start: new_start || 1, new_count: new_count,
|
|
175
|
+
lines: lines.freeze
|
|
176
|
+
)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def collect_hunk_lines(range_start, range_end, raw_diff, old_lines, new_lines)
|
|
180
|
+
acc = { lines: [], old_start: nil, new_start: nil, old_count: 0, new_count: 0 }
|
|
181
181
|
|
|
182
|
-
|
|
183
|
-
|
|
182
|
+
(range_start..range_end).each do |idx|
|
|
183
|
+
apply_diff_entry(acc, raw_diff, idx, old_lines, new_lines)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
acc.values_at(:lines, :old_start, :new_start, :old_count, :new_count)
|
|
187
|
+
end
|
|
184
188
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
)
|
|
189
|
+
def apply_diff_entry(acc, raw_diff, idx, old_lines, new_lines)
|
|
190
|
+
op, old_idx, new_idx = raw_diff[idx]
|
|
191
|
+
case op
|
|
192
|
+
when :equal then apply_equal_entry(acc, old_lines, old_idx, new_idx)
|
|
193
|
+
when :delete then apply_delete_entry(acc, raw_diff, idx, old_lines, old_idx, new_idx)
|
|
194
|
+
when :add then apply_add_entry(acc, raw_diff, idx, new_lines, old_idx, new_idx)
|
|
192
195
|
end
|
|
193
196
|
end
|
|
194
197
|
|
|
198
|
+
def apply_equal_entry(acc, old_lines, old_idx, new_idx)
|
|
199
|
+
acc[:old_start] ||= old_idx + 1
|
|
200
|
+
acc[:new_start] ||= new_idx + 1
|
|
201
|
+
acc[:lines] << DiffLine.new(type: :context, content: old_lines[old_idx])
|
|
202
|
+
acc[:old_count] += 1
|
|
203
|
+
acc[:new_count] += 1
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def apply_delete_entry(acc, raw_diff, idx, old_lines, old_idx, new_idx) # rubocop:disable Metrics/ParameterLists -- diff entry requires context from caller
|
|
207
|
+
acc[:old_start] ||= old_idx + 1
|
|
208
|
+
acc[:new_start] ||= (new_idx || find_new_start(raw_diff, idx)) + 1
|
|
209
|
+
acc[:lines] << DiffLine.new(type: :delete, content: old_lines[old_idx])
|
|
210
|
+
acc[:old_count] += 1
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def apply_add_entry(acc, raw_diff, idx, new_lines, old_idx, new_idx) # rubocop:disable Metrics/ParameterLists -- diff entry requires context from caller
|
|
214
|
+
acc[:old_start] ||= (old_idx || find_old_start(raw_diff, idx)) + 1
|
|
215
|
+
acc[:new_start] ||= new_idx + 1
|
|
216
|
+
acc[:lines] << DiffLine.new(type: :add, content: new_lines[new_idx])
|
|
217
|
+
acc[:new_count] += 1
|
|
218
|
+
end
|
|
219
|
+
|
|
195
220
|
# Find the nearest new-side line number for context when a delete has no new_idx.
|
|
196
221
|
def find_new_start(raw_diff, from_idx)
|
|
197
222
|
((from_idx + 1)...raw_diff.size).each do |i|
|
|
@@ -55,20 +55,20 @@ module RubynCode
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def diff(text)
|
|
58
|
-
lines = text.each_line.map
|
|
59
|
-
case line
|
|
60
|
-
when /\A\+{3}\s/ then pastel.bold(line)
|
|
61
|
-
when /\A-{3}\s/ then pastel.bold(line)
|
|
62
|
-
when /\A@@/ then pastel.cyan(line)
|
|
63
|
-
when /\A\+/ then pastel.green(line)
|
|
64
|
-
when /\A-/ then pastel.red(line)
|
|
65
|
-
else pastel.dim(line)
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
|
|
58
|
+
lines = text.each_line.map { |line| colorize_diff_line(line) }
|
|
69
59
|
output lines.join
|
|
70
60
|
end
|
|
71
61
|
|
|
62
|
+
def colorize_diff_line(line)
|
|
63
|
+
case line
|
|
64
|
+
when /\A[+-]{3}\s/ then pastel.bold(line)
|
|
65
|
+
when /\A@@/ then pastel.cyan(line)
|
|
66
|
+
when /\A\+/ then pastel.green(line)
|
|
67
|
+
when /\A-/ then pastel.red(line)
|
|
68
|
+
else pastel.dim(line)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
72
|
def tool_call(tool_name, arguments = {})
|
|
73
73
|
header = pastel.magenta.bold("#{TOOL_ICON} #{tool_name}")
|
|
74
74
|
parts = [header]
|