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.
Files changed (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +91 -3
  3. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  4. data/lib/rubyn_code/agent/conversation.rb +55 -56
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
  6. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  7. data/lib/rubyn_code/agent/llm_caller.rb +149 -0
  8. data/lib/rubyn_code/agent/loop.rb +175 -683
  9. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  10. data/lib/rubyn_code/agent/prompts.rb +109 -0
  11. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  12. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  13. data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
  14. data/lib/rubyn_code/agent/tool_processor.rb +158 -0
  15. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  16. data/lib/rubyn_code/auth/oauth.rb +80 -64
  17. data/lib/rubyn_code/auth/server.rb +21 -24
  18. data/lib/rubyn_code/auth/token_store.rb +31 -44
  19. data/lib/rubyn_code/autonomous/daemon.rb +29 -18
  20. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
  21. data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
  22. data/lib/rubyn_code/background/worker.rb +64 -76
  23. data/lib/rubyn_code/cli/app.rb +128 -114
  24. data/lib/rubyn_code/cli/commands/model.rb +75 -18
  25. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
  27. data/lib/rubyn_code/cli/renderer.rb +109 -60
  28. data/lib/rubyn_code/cli/repl.rb +42 -373
  29. data/lib/rubyn_code/cli/repl_commands.rb +176 -0
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +145 -0
  32. data/lib/rubyn_code/cli/setup.rb +6 -2
  33. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  34. data/lib/rubyn_code/cli/version_check.rb +28 -11
  35. data/lib/rubyn_code/config/defaults.rb +10 -0
  36. data/lib/rubyn_code/config/project_profile.rb +185 -0
  37. data/lib/rubyn_code/config/settings.rb +100 -1
  38. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  39. data/lib/rubyn_code/context/context_budget.rb +167 -0
  40. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  41. data/lib/rubyn_code/context/manager.rb +7 -5
  42. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  43. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  44. data/lib/rubyn_code/db/connection.rb +31 -26
  45. data/lib/rubyn_code/db/migrator.rb +44 -28
  46. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  47. data/lib/rubyn_code/index/codebase_index.rb +245 -0
  48. data/lib/rubyn_code/learning/extractor.rb +65 -82
  49. data/lib/rubyn_code/learning/injector.rb +22 -23
  50. data/lib/rubyn_code/learning/instinct.rb +71 -42
  51. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  52. data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
  53. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  54. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  55. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  56. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  57. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
  58. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  59. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  60. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  61. data/lib/rubyn_code/llm/client.rb +55 -252
  62. data/lib/rubyn_code/llm/model_router.rb +237 -0
  63. data/lib/rubyn_code/llm/streaming.rb +4 -227
  64. data/lib/rubyn_code/mcp/client.rb +1 -1
  65. data/lib/rubyn_code/mcp/config.rb +9 -12
  66. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  67. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  69. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  70. data/lib/rubyn_code/memory/store.rb +42 -55
  71. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  72. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  73. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  74. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  75. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  76. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  77. data/lib/rubyn_code/output/formatter.rb +11 -11
  78. data/lib/rubyn_code/permissions/policy.rb +11 -13
  79. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  80. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  81. data/lib/rubyn_code/skills/document.rb +33 -29
  82. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  83. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  84. data/lib/rubyn_code/tasks/dag.rb +25 -24
  85. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  86. data/lib/rubyn_code/tools/background_run.rb +2 -1
  87. data/lib/rubyn_code/tools/base.rb +26 -32
  88. data/lib/rubyn_code/tools/bash.rb +2 -1
  89. data/lib/rubyn_code/tools/edit_file.rb +74 -18
  90. data/lib/rubyn_code/tools/executor.rb +74 -24
  91. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  92. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  93. data/lib/rubyn_code/tools/git_log.rb +12 -10
  94. data/lib/rubyn_code/tools/glob.rb +23 -7
  95. data/lib/rubyn_code/tools/grep.rb +2 -1
  96. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  97. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  98. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  99. data/lib/rubyn_code/tools/output_compressor.rb +185 -0
  100. data/lib/rubyn_code/tools/read_file.rb +11 -6
  101. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  102. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  103. data/lib/rubyn_code/tools/schema.rb +4 -10
  104. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  105. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  106. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  107. data/lib/rubyn_code/tools/task.rb +17 -17
  108. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  109. data/lib/rubyn_code/tools/web_search.rb +66 -48
  110. data/lib/rubyn_code/tools/write_file.rb +59 -1
  111. data/lib/rubyn_code/version.rb +1 -1
  112. data/lib/rubyn_code.rb +40 -1
  113. data/skills/rubyn_self_test.md +121 -0
  114. 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
- total_input = rows.sum { |r| fetch_int(r, 'input_tokens') }
31
- total_output = rows.sum { |r| fetch_int(r, 'output_tokens') }
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 = @db.query(
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
- total_input = rows.sum { |r| fetch_int(r, 'input_tokens') }
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 = @db.query(
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' % amount
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(n)
128
- n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
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
- parts = []
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
- m = old_lines.size
88
- n = new_lines.size
89
- table = Array.new(m + 1) { Array.new(n + 1, 0) }
90
-
91
- (1..m).each do |i|
92
- (1..n).each do |j|
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
- i = old_lines.size
109
- j = new_lines.size
110
-
111
- while i.positive? || j.positive?
112
- if i.positive? && j.positive? && old_lines[i - 1] == new_lines[j - 1]
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
- # Group changes that are within context_lines of each other
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
- # Build hunks from groups
149
- groups.map do |group|
150
- range_start = [group.first - @context_lines, 0].max
151
- range_end = [group.last + @context_lines, raw_diff.size - 1].min
152
-
153
- lines = []
154
- old_start = nil
155
- new_start = nil
156
- old_count = 0
157
- new_count = 0
158
-
159
- (range_start..range_end).each do |idx|
160
- op, old_idx, new_idx = raw_diff[idx]
161
-
162
- case op
163
- when :equal
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
- old_start ||= 1
183
- new_start ||= 1
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
- Hunk.new(
186
- old_start: old_start,
187
- old_count: old_count,
188
- new_start: new_start,
189
- new_count: new_count,
190
- lines: lines.freeze
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 do |line|
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]