rubyn-code 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.
Files changed (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. metadata +397 -0
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module RubynCode
6
+ module Observability
7
+ # Generates human-readable cost and usage summaries from recorded cost data.
8
+ class UsageReporter
9
+ TABLE_NAME = BudgetEnforcer::TABLE_NAME
10
+
11
+ # @param db [DB::Connection] database connection
12
+ # @param formatter [Output::Formatter] output formatter for colorized text
13
+ def initialize(db, formatter:)
14
+ @db = db
15
+ @formatter = formatter
16
+ end
17
+
18
+ # Returns a formatted summary of cost and usage for a given session.
19
+ #
20
+ # @param session_id [String]
21
+ # @return [String] multi-line formatted summary
22
+ def session_summary(session_id)
23
+ rows = @db.query(
24
+ "SELECT model, input_tokens, output_tokens, cost_usd FROM #{TABLE_NAME} WHERE session_id = ?",
25
+ [session_id]
26
+ ).to_a
27
+
28
+ return "No usage data for session #{session_id}." if rows.empty?
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")
48
+ end
49
+
50
+ # Returns a formatted summary of today's total cost across all sessions.
51
+ #
52
+ # @return [String] multi-line formatted summary
53
+ def daily_summary
54
+ 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
61
+
62
+ return "No usage data for today." if rows.empty?
63
+
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")
80
+ end
81
+
82
+ # Returns a cost breakdown by model for a given session.
83
+ #
84
+ # @param session_id [String]
85
+ # @return [String] multi-line formatted breakdown
86
+ 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
93
+
94
+ return "No usage data for session #{session_id}." if rows.empty?
95
+
96
+ 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
+
109
+ lines.join("\n")
110
+ end
111
+
112
+ private
113
+
114
+ def header(title)
115
+ bar = @formatter.pastel.dim("-" * 40)
116
+ "#{bar}\n #{@formatter.pastel.bold(title)}\n#{bar}"
117
+ end
118
+
119
+ def field(label, value)
120
+ " #{@formatter.pastel.dim("#{label}:")} #{value}"
121
+ end
122
+
123
+ def format_usd(amount)
124
+ "$%.4f" % amount
125
+ end
126
+
127
+ def format_number(n)
128
+ n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
129
+ end
130
+
131
+ def fetch_int(row, key)
132
+ (row[key] || row[key.to_sym] || 0).to_i
133
+ end
134
+
135
+ def fetch_float(row, key)
136
+ (row[key] || row[key.to_sym] || 0.0).to_f
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+
5
+ module RubynCode
6
+ module Output
7
+ class DiffRenderer
8
+ # Immutable value object representing a single hunk in a unified diff.
9
+ Hunk = Data.define(:old_start, :old_count, :new_start, :new_count, :lines)
10
+
11
+ # Represents a single diff line with its type and content.
12
+ DiffLine = Data.define(:type, :content) do
13
+ def addition? = type == :add
14
+ def deletion? = type == :delete
15
+ def context? = type == :context
16
+ end
17
+
18
+ attr_reader :pastel
19
+
20
+ # @param enabled [Boolean] whether color output is enabled
21
+ # @param context_lines [Integer] number of context lines around changes
22
+ def initialize(enabled: $stdout.tty?, context_lines: 3)
23
+ @pastel = Pastel.new(enabled: enabled)
24
+ @context_lines = context_lines
25
+ end
26
+
27
+ # Renders a unified diff between old_text and new_text.
28
+ #
29
+ # @param old_text [String] the original text
30
+ # @param new_text [String] the modified text
31
+ # @param filename [String] the filename to display in the diff header
32
+ # @return [String] the rendered, colorized diff output
33
+ def render(old_text, new_text, filename: "file")
34
+ old_lines = old_text.lines.map(&:chomp)
35
+ new_lines = new_text.lines.map(&:chomp)
36
+
37
+ hunks = compute_hunks(old_lines, new_lines)
38
+ return pastel.dim("No differences found.") if hunks.empty?
39
+
40
+ parts = []
41
+ parts << render_header(filename)
42
+ hunks.each { |hunk| parts << render_hunk(hunk) }
43
+ parts << ""
44
+
45
+ result = parts.join("\n")
46
+ $stdout.puts(result)
47
+ result
48
+ end
49
+
50
+ private
51
+
52
+ def render_header(filename)
53
+ [
54
+ pastel.bold("--- a/#{filename}"),
55
+ pastel.bold("+++ b/#{filename}")
56
+ ].join("\n")
57
+ end
58
+
59
+ def render_hunk(hunk)
60
+ header = pastel.cyan(
61
+ "@@ -#{hunk.old_start},#{hunk.old_count} +#{hunk.new_start},#{hunk.new_count} @@"
62
+ )
63
+
64
+ rendered_lines = hunk.lines.map do |diff_line|
65
+ case diff_line
66
+ in DiffLine[type: :add, content:]
67
+ pastel.green("+#{content}")
68
+ in DiffLine[type: :delete, content:]
69
+ pastel.red("-#{content}")
70
+ in DiffLine[type: :context, content:]
71
+ pastel.dim(" #{content}")
72
+ end
73
+ end
74
+
75
+ [header, *rendered_lines].join("\n")
76
+ end
77
+
78
+ # Computes unified-diff hunks using the Myers diff algorithm (simple LCS approach).
79
+ def compute_hunks(old_lines, new_lines)
80
+ lcs_table = build_lcs_table(old_lines, new_lines)
81
+ raw_diff = backtrack_diff(lcs_table, old_lines, new_lines)
82
+ group_into_hunks(raw_diff, old_lines, new_lines)
83
+ end
84
+
85
+ # Builds the LCS length table for two arrays of lines.
86
+ 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
99
+ end
100
+
101
+ table
102
+ end
103
+
104
+ # Backtracks through the LCS table to produce a sequence of diff operations.
105
+ # Returns an array of [:equal, :delete, :add] paired with line indices.
106
+ def backtrack_diff(table, old_lines, new_lines)
107
+ 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
123
+ end
124
+
125
+ result
126
+ end
127
+
128
+ # Groups raw diff operations into hunks with surrounding context lines.
129
+ def group_into_hunks(raw_diff, old_lines, new_lines)
130
+ # Identify change indices (non-equal operations)
131
+ change_indices = raw_diff.each_index.select { |idx| raw_diff[idx][0] != :equal }
132
+ return [] if change_indices.empty?
133
+
134
+ # Group changes that are within context_lines of each other
135
+ groups = []
136
+ current_group = [change_indices.first]
137
+
138
+ change_indices.drop(1).each do |idx|
139
+ if idx - current_group.last <= @context_lines * 2 + 1
140
+ current_group << idx
141
+ else
142
+ groups << current_group
143
+ current_group = [idx]
144
+ end
145
+ end
146
+ groups << current_group
147
+
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
181
+
182
+ old_start ||= 1
183
+ new_start ||= 1
184
+
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
+ )
192
+ end
193
+ end
194
+
195
+ # Find the nearest new-side line number for context when a delete has no new_idx.
196
+ def find_new_start(raw_diff, from_idx)
197
+ ((from_idx + 1)...raw_diff.size).each do |i|
198
+ return raw_diff[i][2] if raw_diff[i][2]
199
+ end
200
+ 0
201
+ end
202
+
203
+ # Find the nearest old-side line number for context when an add has no old_idx.
204
+ def find_old_start(raw_diff, from_idx)
205
+ ((from_idx + 1)...raw_diff.size).each do |i|
206
+ return raw_diff[i][1] if raw_diff[i][1]
207
+ end
208
+ 0
209
+ end
210
+ end
211
+ end
212
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pastel"
4
+ require "rouge"
5
+
6
+ module RubynCode
7
+ module Output
8
+ class Formatter
9
+ TOOL_ICON = "\u{1F527}" # wrench
10
+ AGENT_ICON = "\u{1F916}" # robot
11
+
12
+ attr_reader :pastel
13
+
14
+ def initialize(enabled: $stdout.tty?)
15
+ @pastel = Pastel.new(enabled: enabled)
16
+ end
17
+
18
+ def success(message)
19
+ output pastel.green("\u2714 #{message}")
20
+ end
21
+
22
+ def error(message)
23
+ output pastel.red("\u2718 #{message}")
24
+ end
25
+
26
+ def warning(message)
27
+ output pastel.yellow("\u26A0 #{message}")
28
+ end
29
+
30
+ def info(message)
31
+ output pastel.cyan("\u2139 #{message}")
32
+ end
33
+
34
+ def dim(message)
35
+ output pastel.dim(message)
36
+ end
37
+
38
+ def bold(message)
39
+ output pastel.bold(message)
40
+ end
41
+
42
+ def code_block(code, language: "ruby")
43
+ lexer = find_lexer(language)
44
+ formatter = Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
45
+
46
+ highlighted = formatter.format(lexer.lex(code))
47
+
48
+ lines = [
49
+ pastel.dim("\u2500" * 40),
50
+ highlighted,
51
+ pastel.dim("\u2500" * 40)
52
+ ]
53
+
54
+ output lines.join("\n")
55
+ end
56
+
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
+
69
+ output lines.join
70
+ end
71
+
72
+ def tool_call(tool_name, arguments = {})
73
+ header = pastel.magenta.bold("#{TOOL_ICON} #{tool_name}")
74
+ parts = [header]
75
+
76
+ unless arguments.empty?
77
+ args_display = arguments.map do |key, value|
78
+ display_value = truncate(value.to_s, 120)
79
+ " #{pastel.dim("#{key}:")} #{display_value}"
80
+ end
81
+ parts.concat(args_display)
82
+ end
83
+
84
+ output parts.join("\n")
85
+ end
86
+
87
+ def tool_result(tool_name, result, success: true)
88
+ status = success ? pastel.green("\u2714") : pastel.red("\u2718")
89
+ header = pastel.magenta("#{status} #{tool_name}")
90
+ result_text = truncate(result.to_s, 500)
91
+
92
+ output "#{header}\n#{pastel.dim(result_text)}"
93
+ end
94
+
95
+ def agent_message(message)
96
+ prefix = pastel.blue.bold("#{AGENT_ICON} Assistant")
97
+ output "#{prefix}\n#{message}"
98
+ end
99
+
100
+ private
101
+
102
+ def output(text)
103
+ $stdout.puts(text)
104
+ text
105
+ end
106
+
107
+ def truncate(text, max_length)
108
+ return text if text.length <= max_length
109
+
110
+ "#{text[0, max_length]}#{pastel.dim("... (truncated)")}"
111
+ end
112
+
113
+ def find_lexer(language)
114
+ Rouge::Lexer.find(language.to_s) || Rouge::Lexers::PlainText.new
115
+ rescue StandardError
116
+ Rouge::Lexers::PlainText.new
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Permissions
5
+ class DenyList
6
+ attr_reader :names, :prefixes
7
+
8
+ # @param names [Array<String>] exact tool names to deny
9
+ # @param prefixes [Array<String>] tool name prefixes to deny
10
+ def initialize(names: [], prefixes: [])
11
+ @names = Set.new(names.map(&:to_s))
12
+ @prefixes = Set.new(prefixes.map(&:to_s))
13
+ end
14
+
15
+ # Returns true if the given tool name is blocked by an exact name match
16
+ # or by a prefix match.
17
+ #
18
+ # @param tool_name [String]
19
+ # @return [Boolean]
20
+ def blocks?(tool_name)
21
+ name = tool_name.to_s
22
+ return true if @names.include?(name)
23
+
24
+ @prefixes.any? { |prefix| name.start_with?(prefix) }
25
+ end
26
+
27
+ # @param name [String] exact tool name to add to the deny list
28
+ # @return [self]
29
+ def add_name(name)
30
+ @names.add(name.to_s)
31
+ self
32
+ end
33
+
34
+ # @param prefix [String] tool name prefix to add to the deny list
35
+ # @return [self]
36
+ def add_prefix(prefix)
37
+ @prefixes.add(prefix.to_s)
38
+ self
39
+ end
40
+
41
+ # @param name [String] exact tool name to remove from the deny list
42
+ # @return [self]
43
+ def remove_name(name)
44
+ @names.delete(name.to_s)
45
+ self
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Permissions
5
+ module Policy
6
+ # Determine whether a tool invocation should be allowed, denied, or
7
+ # requires user confirmation.
8
+ #
9
+ # @param tool_name [String]
10
+ # @param tool_input [Hash]
11
+ # @param tier [Symbol] one of Tier::ALL
12
+ # @param deny_list [DenyList]
13
+ # @return [Symbol] :allow, :deny, or :ask
14
+ # Tool calls that are always auto-approved regardless of permission tier
15
+ ALWAYS_ALLOW = %w[
16
+ read_file glob grep git_status git_diff git_log
17
+ memory_search memory_write load_skill compact
18
+ task web_search web_fetch
19
+ ].to_set.freeze
20
+
21
+ def self.check(tool_name:, tool_input:, tier:, deny_list:)
22
+ return :deny if deny_list.blocks?(tool_name)
23
+ return :allow if ALWAYS_ALLOW.include?(tool_name)
24
+
25
+ risk = resolve_risk(tool_name)
26
+
27
+ return :ask if risk == :destructive
28
+
29
+ case tier
30
+ when Tier::ASK_ALWAYS
31
+ :ask
32
+ when Tier::ALLOW_READ
33
+ risk == :read ? :allow : :ask
34
+ when Tier::AUTONOMOUS
35
+ risk == :external ? :ask : :allow
36
+ when Tier::UNRESTRICTED
37
+ :allow
38
+ else
39
+ :ask
40
+ end
41
+ end
42
+
43
+ # Resolve the risk level for a tool by looking it up in the registry.
44
+ # Falls back to :unknown if the tool class cannot be found, which will
45
+ # be treated conservatively (requires confirmation in most tiers).
46
+ #
47
+ # @param tool_name [String]
48
+ # @return [Symbol] :read, :write, :external, :destructive, or :unknown
49
+ def self.resolve_risk(tool_name)
50
+ tool_class = Tools::Registry.get(tool_name)
51
+ tool_class.risk_level
52
+ rescue ToolNotFoundError
53
+ :unknown
54
+ end
55
+
56
+ private_class_method :resolve_risk
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tty-prompt"
4
+ require "pastel"
5
+ require "json"
6
+
7
+ module RubynCode
8
+ module Permissions
9
+ module Prompter
10
+ # Ask the user to confirm a regular tool invocation.
11
+ #
12
+ # @param tool_name [String]
13
+ # @param tool_input [Hash]
14
+ # @return [Boolean] true if the user approved
15
+ def self.confirm(tool_name, tool_input)
16
+ prompt = build_prompt
17
+ pastel = Pastel.new
18
+
19
+ display_tool_summary(pastel, tool_name, tool_input)
20
+
21
+ prompt.yes?(
22
+ pastel.yellow("Allow this tool call?"),
23
+ default: true
24
+ )
25
+ rescue TTY::Prompt::Reader::InputInterrupt
26
+ false
27
+ end
28
+
29
+ # Ask the user to confirm a destructive tool invocation.
30
+ # Requires the user to type "yes" explicitly rather than just pressing Enter.
31
+ #
32
+ # @param tool_name [String]
33
+ # @param tool_input [Hash]
34
+ # @return [Boolean] true if the user approved
35
+ def self.confirm_destructive(tool_name, tool_input)
36
+ prompt = build_prompt
37
+ pastel = Pastel.new
38
+
39
+ $stdout.puts pastel.red.bold("WARNING: Destructive operation requested")
40
+ $stdout.puts pastel.red("=" * 50)
41
+ display_tool_summary(pastel, tool_name, tool_input)
42
+ $stdout.puts pastel.red("=" * 50)
43
+
44
+ answer = prompt.ask(
45
+ pastel.red.bold('Type "yes" to confirm this destructive action:')
46
+ )
47
+
48
+ answer&.strip&.downcase == "yes"
49
+ rescue TTY::Prompt::Reader::InputInterrupt
50
+ false
51
+ end
52
+
53
+ # @api private
54
+ def self.build_prompt
55
+ TTY::Prompt.new(interrupt: :noop)
56
+ end
57
+
58
+ # @api private
59
+ def self.display_tool_summary(pastel, tool_name, tool_input)
60
+ $stdout.puts pastel.magenta.bold("Tool: #{tool_name}")
61
+
62
+ return if tool_input.nil? || tool_input.empty?
63
+
64
+ tool_input.each do |key, value|
65
+ display_value = truncate_value(value.to_s, 200)
66
+ $stdout.puts " #{pastel.dim("#{key}:")} #{display_value}"
67
+ end
68
+ end
69
+
70
+ # @api private
71
+ def self.truncate_value(text, max_length)
72
+ return text if text.length <= max_length
73
+
74
+ "#{text[0, max_length]}... (truncated)"
75
+ end
76
+
77
+ private_class_method :build_prompt, :display_tool_summary, :truncate_value
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Permissions
5
+ module Tier
6
+ ASK_ALWAYS = :ask_always
7
+ ALLOW_READ = :allow_read
8
+ AUTONOMOUS = :autonomous
9
+ UNRESTRICTED = :unrestricted
10
+
11
+ ALL = [ASK_ALWAYS, ALLOW_READ, AUTONOMOUS, UNRESTRICTED].freeze
12
+
13
+ def self.all
14
+ ALL
15
+ end
16
+
17
+ def self.valid?(tier)
18
+ ALL.include?(tier)
19
+ end
20
+ end
21
+ end
22
+ end