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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- 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
|