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,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Tools
|
|
8
|
+
class SpawnTeammate < Base
|
|
9
|
+
TOOL_NAME = "spawn_teammate"
|
|
10
|
+
DESCRIPTION = "Spawn a persistent named teammate agent with a role and an initial task. " \
|
|
11
|
+
"The teammate gets its own conversation, processes the initial prompt, and " \
|
|
12
|
+
"remains available via the mailbox for further messages."
|
|
13
|
+
PARAMETERS = {
|
|
14
|
+
name: {
|
|
15
|
+
type: :string,
|
|
16
|
+
description: "Unique name for the teammate",
|
|
17
|
+
required: true
|
|
18
|
+
},
|
|
19
|
+
role: {
|
|
20
|
+
type: :string,
|
|
21
|
+
description: "The teammate's role (e.g. 'coder', 'reviewer', 'tester')",
|
|
22
|
+
required: true
|
|
23
|
+
},
|
|
24
|
+
prompt: {
|
|
25
|
+
type: :string,
|
|
26
|
+
description: "Initial task or instruction for the teammate",
|
|
27
|
+
required: true
|
|
28
|
+
}
|
|
29
|
+
}.freeze
|
|
30
|
+
RISK_LEVEL = :execute
|
|
31
|
+
|
|
32
|
+
attr_writer :llm_client, :on_status, :db
|
|
33
|
+
|
|
34
|
+
def execute(name:, role:, prompt:)
|
|
35
|
+
callback = @on_status || method(:default_status)
|
|
36
|
+
|
|
37
|
+
raise Error, "LLM client not available" unless @llm_client
|
|
38
|
+
raise Error, "Database not available" unless @db
|
|
39
|
+
|
|
40
|
+
mailbox = Teams::Mailbox.new(@db)
|
|
41
|
+
manager = Teams::Manager.new(@db, mailbox: mailbox)
|
|
42
|
+
|
|
43
|
+
teammate = manager.spawn(name: name, role: role)
|
|
44
|
+
callback.call(:started, "Spawning teammate '#{name}' as #{role}...")
|
|
45
|
+
|
|
46
|
+
# Spawn a background thread running the teammate agent
|
|
47
|
+
Thread.new do
|
|
48
|
+
run_teammate_agent(teammate, prompt, mailbox, callback)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
"Spawned teammate '#{name}' as #{role}. Initial task: #{prompt[0..100]}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def run_teammate_agent(teammate, initial_prompt, mailbox, callback)
|
|
57
|
+
conversation = Agent::Conversation.new
|
|
58
|
+
conversation.add_user_message(initial_prompt)
|
|
59
|
+
|
|
60
|
+
system_prompt = "You are #{teammate.name}, a #{teammate.role} teammate agent. " \
|
|
61
|
+
"Complete tasks efficiently. Use tools when needed. " \
|
|
62
|
+
"When done, provide a clear summary of what you accomplished."
|
|
63
|
+
|
|
64
|
+
tools = tools_for_teammate
|
|
65
|
+
max_iterations = Config::Defaults::MAX_SUB_AGENT_ITERATIONS
|
|
66
|
+
|
|
67
|
+
max_iterations.times do
|
|
68
|
+
response = @llm_client.chat(
|
|
69
|
+
messages: conversation.to_api_format,
|
|
70
|
+
tools: tools,
|
|
71
|
+
system: system_prompt
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
content = response.respond_to?(:content) ? Array(response.content) : []
|
|
75
|
+
tool_calls = content.select { |b| b.respond_to?(:type) && b.type == "tool_use" }
|
|
76
|
+
|
|
77
|
+
if tool_calls.empty?
|
|
78
|
+
text = content.select { |b| b.respond_to?(:type) && b.type == "text" }
|
|
79
|
+
.map(&:text).join("\n")
|
|
80
|
+
conversation.add_assistant_message(content)
|
|
81
|
+
callback.call(:done, "Teammate '#{teammate.name}' finished initial task.")
|
|
82
|
+
|
|
83
|
+
# Send result back to main agent inbox
|
|
84
|
+
mailbox.send(from: teammate.name, to: "rubyn", content: text)
|
|
85
|
+
|
|
86
|
+
# Now loop waiting for new messages
|
|
87
|
+
poll_inbox(teammate, conversation, tools, system_prompt, mailbox, callback)
|
|
88
|
+
return
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
conversation.add_assistant_message(content)
|
|
92
|
+
execute_tool_calls(tool_calls, conversation, callback)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
callback.call(:done, "Teammate '#{teammate.name}' reached iteration limit.")
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
callback.call(:done, "Teammate '#{teammate.name}' error: #{e.message}")
|
|
98
|
+
$stderr.puts "[Teammate #{teammate.name}] Error: #{e.class}: #{e.message}" if ENV["RUBYN_DEBUG"]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def poll_inbox(teammate, conversation, tools, system_prompt, mailbox, callback)
|
|
102
|
+
loop do
|
|
103
|
+
sleep Config::Defaults::POLL_INTERVAL
|
|
104
|
+
|
|
105
|
+
messages = mailbox.read_inbox(teammate.name)
|
|
106
|
+
next if messages.empty?
|
|
107
|
+
|
|
108
|
+
messages.each do |msg|
|
|
109
|
+
conversation.add_user_message(msg[:content])
|
|
110
|
+
|
|
111
|
+
response = @llm_client.chat(
|
|
112
|
+
messages: conversation.to_api_format,
|
|
113
|
+
tools: tools,
|
|
114
|
+
system: system_prompt
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
content = response.respond_to?(:content) ? Array(response.content) : []
|
|
118
|
+
conversation.add_assistant_message(content)
|
|
119
|
+
|
|
120
|
+
text = content.select { |b| b.respond_to?(:type) && b.type == "text" }
|
|
121
|
+
.map(&:text).join("\n")
|
|
122
|
+
mailbox.send(from: teammate.name, to: msg[:from], content: text) unless text.empty?
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
$stderr.puts "[Teammate #{teammate.name}] Poll error: #{e.message}" if ENV["RUBYN_DEBUG"]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def execute_tool_calls(tool_calls, conversation, callback)
|
|
130
|
+
tool_calls.each do |tc|
|
|
131
|
+
name = tc.respond_to?(:name) ? tc.name : tc[:name]
|
|
132
|
+
input = tc.respond_to?(:input) ? tc.input : tc[:input]
|
|
133
|
+
id = tc.respond_to?(:id) ? tc.id : tc[:id]
|
|
134
|
+
|
|
135
|
+
callback.call(:tool, " [teammate] > #{name}")
|
|
136
|
+
|
|
137
|
+
begin
|
|
138
|
+
# Block recursive spawning
|
|
139
|
+
if %w[spawn_agent spawn_teammate].include?(name)
|
|
140
|
+
conversation.add_tool_result(id, name, "Error: Teammates cannot spawn other agents.", is_error: true)
|
|
141
|
+
next
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
tool_class = Registry.get(name)
|
|
145
|
+
tool = tool_class.new(project_root: project_root)
|
|
146
|
+
result = tool.execute(**input.transform_keys(&:to_sym))
|
|
147
|
+
truncated = tool.truncate(result.to_s)
|
|
148
|
+
conversation.add_tool_result(id, name, truncated)
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
conversation.add_tool_result(id, name, "Error: #{e.message}", is_error: true)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def tools_for_teammate
|
|
156
|
+
all_tools = Registry.tool_definitions
|
|
157
|
+
blocked = %w[spawn_agent spawn_teammate send_message read_inbox compact]
|
|
158
|
+
all_tools.reject { |t| blocked.include?(t[:name]) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def default_status(_type, message)
|
|
162
|
+
$stderr.puts "[spawn_teammate] #{message}" if ENV["RUBYN_DEBUG"]
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
Registry.register(SpawnTeammate)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Tools
|
|
8
|
+
class Task < Base
|
|
9
|
+
TOOL_NAME = "task"
|
|
10
|
+
DESCRIPTION = "Manage tasks: create, update, complete, list, or get tasks for tracking work items and dependencies."
|
|
11
|
+
PARAMETERS = {
|
|
12
|
+
action: {
|
|
13
|
+
type: :string, required: true,
|
|
14
|
+
description: "Action to perform: create, update, complete, list, get"
|
|
15
|
+
},
|
|
16
|
+
title: {
|
|
17
|
+
type: :string, required: false,
|
|
18
|
+
description: "Task title (required for create)"
|
|
19
|
+
},
|
|
20
|
+
description: {
|
|
21
|
+
type: :string, required: false,
|
|
22
|
+
description: "Task description"
|
|
23
|
+
},
|
|
24
|
+
task_id: {
|
|
25
|
+
type: :string, required: false,
|
|
26
|
+
description: "Task ID (required for update, complete, get)"
|
|
27
|
+
},
|
|
28
|
+
status: {
|
|
29
|
+
type: :string, required: false,
|
|
30
|
+
description: "Filter by status (for list) or set status (for update)"
|
|
31
|
+
},
|
|
32
|
+
session_id: {
|
|
33
|
+
type: :string, required: false,
|
|
34
|
+
description: "Session ID for scoping tasks"
|
|
35
|
+
},
|
|
36
|
+
priority: {
|
|
37
|
+
type: :integer, required: false,
|
|
38
|
+
description: "Task priority (higher = more important)"
|
|
39
|
+
},
|
|
40
|
+
blocked_by: {
|
|
41
|
+
type: :array, required: false,
|
|
42
|
+
description: "Array of task IDs this task depends on (for create)"
|
|
43
|
+
},
|
|
44
|
+
result: {
|
|
45
|
+
type: :string, required: false,
|
|
46
|
+
description: "Result text (for complete)"
|
|
47
|
+
},
|
|
48
|
+
owner: {
|
|
49
|
+
type: :string, required: false,
|
|
50
|
+
description: "Owner identifier (for update)"
|
|
51
|
+
}
|
|
52
|
+
}.freeze
|
|
53
|
+
RISK_LEVEL = :write
|
|
54
|
+
REQUIRES_CONFIRMATION = false
|
|
55
|
+
|
|
56
|
+
def execute(action:, **params)
|
|
57
|
+
manager = Tasks::Manager.new(DB::Connection.instance)
|
|
58
|
+
|
|
59
|
+
case action
|
|
60
|
+
when "create" then execute_create(manager, **params)
|
|
61
|
+
when "update" then execute_update(manager, **params)
|
|
62
|
+
when "complete" then execute_complete(manager, **params)
|
|
63
|
+
when "list" then execute_list(manager, **params)
|
|
64
|
+
when "get" then execute_get(manager, **params)
|
|
65
|
+
else
|
|
66
|
+
raise Error, "Unknown task action: #{action}. Valid actions: create, update, complete, list, get"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def execute_create(manager, title: nil, description: nil, session_id: nil, blocked_by: [], priority: 0, **)
|
|
73
|
+
raise Error, "title is required for create" if title.nil? || title.empty?
|
|
74
|
+
|
|
75
|
+
task = manager.create(
|
|
76
|
+
title: title,
|
|
77
|
+
description: description,
|
|
78
|
+
session_id: session_id,
|
|
79
|
+
blocked_by: Array(blocked_by),
|
|
80
|
+
priority: priority.to_i
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
format_task(task, prefix: "Created task")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def execute_update(manager, task_id: nil, **params)
|
|
87
|
+
raise Error, "task_id is required for update" if task_id.nil? || task_id.empty?
|
|
88
|
+
|
|
89
|
+
attrs = params.slice(:status, :priority, :owner, :result, :description, :title, :metadata)
|
|
90
|
+
attrs[:priority] = attrs[:priority].to_i if attrs.key?(:priority)
|
|
91
|
+
|
|
92
|
+
task = manager.update(task_id, **attrs)
|
|
93
|
+
raise Error, "Task not found: #{task_id}" if task.nil?
|
|
94
|
+
|
|
95
|
+
format_task(task, prefix: "Updated task")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def execute_complete(manager, task_id: nil, result: nil, **)
|
|
99
|
+
raise Error, "task_id is required for complete" if task_id.nil? || task_id.empty?
|
|
100
|
+
|
|
101
|
+
task = manager.complete(task_id, result: result)
|
|
102
|
+
raise Error, "Task not found: #{task_id}" if task.nil?
|
|
103
|
+
|
|
104
|
+
format_task(task, prefix: "Completed task")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def execute_list(manager, status: nil, session_id: nil, **)
|
|
108
|
+
tasks = manager.list(status: status, session_id: session_id)
|
|
109
|
+
|
|
110
|
+
return "No tasks found." if tasks.empty?
|
|
111
|
+
|
|
112
|
+
lines = tasks.map { |t| format_task_line(t) }
|
|
113
|
+
"Found #{tasks.size} task(s):\n\n#{lines.join("\n")}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def execute_get(manager, task_id: nil, **)
|
|
117
|
+
raise Error, "task_id is required for get" if task_id.nil? || task_id.empty?
|
|
118
|
+
|
|
119
|
+
task = manager.get(task_id)
|
|
120
|
+
raise Error, "Task not found: #{task_id}" if task.nil?
|
|
121
|
+
|
|
122
|
+
format_task(task)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def format_task(task, prefix: nil)
|
|
126
|
+
header = prefix ? "#{prefix}: #{task.title}" : task.title
|
|
127
|
+
parts = [
|
|
128
|
+
header,
|
|
129
|
+
" ID: #{task.id}",
|
|
130
|
+
" Status: #{task.status}",
|
|
131
|
+
" Priority: #{task.priority}"
|
|
132
|
+
]
|
|
133
|
+
parts << " Owner: #{task.owner}" if task.owner
|
|
134
|
+
parts << " Result: #{task.result}" if task.result
|
|
135
|
+
parts << " Session: #{task.session_id}" if task.session_id
|
|
136
|
+
parts << " Description: #{task.description}" if task.description
|
|
137
|
+
parts.join("\n")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def format_task_line(task)
|
|
141
|
+
owner_part = task.owner ? " (#{task.owner})" : ""
|
|
142
|
+
"[#{task.status}] #{task.title} (#{task.id[0, 8]}...)#{owner_part} priority=#{task.priority}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
Registry.register(Task)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
require_relative "registry"
|
|
6
|
+
|
|
7
|
+
module RubynCode
|
|
8
|
+
module Tools
|
|
9
|
+
class WebFetch < Base
|
|
10
|
+
TOOL_NAME = "web_fetch"
|
|
11
|
+
DESCRIPTION = "Fetch the content of a web page and return it as text. Useful for reading documentation, READMEs, or API docs."
|
|
12
|
+
PARAMETERS = {
|
|
13
|
+
url: { type: :string, required: true, description: "The URL to fetch (must start with http:// or https://)" },
|
|
14
|
+
max_length: { type: :integer, required: false, default: 10_000, description: "Maximum number of characters to return (default: 10000)" }
|
|
15
|
+
}.freeze
|
|
16
|
+
RISK_LEVEL = :external
|
|
17
|
+
REQUIRES_CONFIRMATION = true
|
|
18
|
+
|
|
19
|
+
def execute(url:, max_length: 10_000)
|
|
20
|
+
validate_url!(url)
|
|
21
|
+
max_length = [[max_length.to_i, 500].max, 100_000].min
|
|
22
|
+
|
|
23
|
+
response = fetch_page(url)
|
|
24
|
+
text = html_to_text(response.body)
|
|
25
|
+
text = collapse_whitespace(text)
|
|
26
|
+
|
|
27
|
+
if text.strip.empty?
|
|
28
|
+
"Fetched #{url} but no readable text content was found."
|
|
29
|
+
else
|
|
30
|
+
header = "Content from: #{url}\n#{"=" * 60}\n\n"
|
|
31
|
+
available = max_length - header.length
|
|
32
|
+
content = text.length > available ? "#{text[0, available]}\n\n... [truncated at #{max_length} characters]" : text
|
|
33
|
+
"#{header}#{content}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def validate_url!(url)
|
|
40
|
+
unless url.match?(%r{\Ahttps?://}i)
|
|
41
|
+
raise Error, "Invalid URL: must start with http:// or https:// — got: #{url}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def fetch_page(url)
|
|
46
|
+
conn = Faraday.new do |f|
|
|
47
|
+
f.options.timeout = 30
|
|
48
|
+
f.options.open_timeout = 10
|
|
49
|
+
f.headers["User-Agent"] = "Mozilla/5.0 (compatible; RubynCode/1.0)"
|
|
50
|
+
f.headers["Accept"] = "text/html,application/xhtml+xml,text/plain,*/*"
|
|
51
|
+
f.response :follow_redirects, limit: 5
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
response = conn.get(url)
|
|
55
|
+
|
|
56
|
+
unless response.success?
|
|
57
|
+
raise Error, "HTTP #{response.status} fetching #{url}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
response
|
|
61
|
+
rescue Faraday::TimeoutError
|
|
62
|
+
raise Error, "Request timed out after 30 seconds fetching #{url}"
|
|
63
|
+
rescue Faraday::ConnectionFailed => e
|
|
64
|
+
raise Error, "Connection failed for #{url}: #{e.message}"
|
|
65
|
+
rescue Faraday::Error => e
|
|
66
|
+
raise Error, "Request failed for #{url}: #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def html_to_text(html)
|
|
70
|
+
return "" if html.nil? || html.empty?
|
|
71
|
+
|
|
72
|
+
text = html.dup
|
|
73
|
+
|
|
74
|
+
# Remove script and style blocks entirely
|
|
75
|
+
text.gsub!(%r{<script[^>]*>.*?</script>}mi, "")
|
|
76
|
+
text.gsub!(%r{<style[^>]*>.*?</style>}mi, "")
|
|
77
|
+
|
|
78
|
+
# Convert common block elements to newlines
|
|
79
|
+
text.gsub!(%r{<br\s*/?>}i, "\n")
|
|
80
|
+
text.gsub!(%r{</(p|div|h[1-6]|li|tr|blockquote|pre)>}i, "\n")
|
|
81
|
+
text.gsub!(%r{<(p|div|h[1-6]|li|tr|blockquote|pre)[^>]*>}i, "\n")
|
|
82
|
+
|
|
83
|
+
# Strip all remaining HTML tags
|
|
84
|
+
text.gsub!(/<[^>]*>/, "")
|
|
85
|
+
|
|
86
|
+
# Decode common HTML entities
|
|
87
|
+
text.gsub!("&", "&")
|
|
88
|
+
text.gsub!("<", "<")
|
|
89
|
+
text.gsub!(">", ">")
|
|
90
|
+
text.gsub!(""", '"')
|
|
91
|
+
text.gsub!("'", "'")
|
|
92
|
+
text.gsub!(" ", " ")
|
|
93
|
+
text.gsub!(/&#(\d+);/) { [$1.to_i].pack("U") }
|
|
94
|
+
|
|
95
|
+
text
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def collapse_whitespace(text)
|
|
99
|
+
# Collapse runs of spaces/tabs on each line, then collapse 3+ newlines into 2
|
|
100
|
+
text.gsub(/[^\S\n]+/, " ")
|
|
101
|
+
.gsub(/\n{3,}/, "\n\n")
|
|
102
|
+
.strip
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
Registry.register(WebFetch)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "cgi"
|
|
5
|
+
require "json"
|
|
6
|
+
require "faraday"
|
|
7
|
+
require_relative "base"
|
|
8
|
+
require_relative "registry"
|
|
9
|
+
|
|
10
|
+
module RubynCode
|
|
11
|
+
module Tools
|
|
12
|
+
class WebSearch < Base
|
|
13
|
+
TOOL_NAME = "web_search"
|
|
14
|
+
DESCRIPTION = "Search the web for information. Returns search results with titles, URLs, and snippets."
|
|
15
|
+
PARAMETERS = {
|
|
16
|
+
query: { type: :string, required: true, description: "The search query string" },
|
|
17
|
+
num_results: { type: :integer, required: false, default: 5, description: "Number of results (default: 5)" }
|
|
18
|
+
}.freeze
|
|
19
|
+
RISK_LEVEL = :external
|
|
20
|
+
REQUIRES_CONFIRMATION = true
|
|
21
|
+
|
|
22
|
+
# Adapter registry — add new providers here
|
|
23
|
+
ADAPTERS = {
|
|
24
|
+
"duckduckgo" => :search_duckduckgo,
|
|
25
|
+
"brave" => :search_brave,
|
|
26
|
+
"serpapi" => :search_serpapi,
|
|
27
|
+
"tavily" => :search_tavily,
|
|
28
|
+
"google" => :search_google
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
def execute(query:, num_results: 5)
|
|
32
|
+
num_results = [[num_results.to_i, 1].max, 20].min
|
|
33
|
+
provider = detect_provider
|
|
34
|
+
|
|
35
|
+
results = send(ADAPTERS[provider], query, num_results)
|
|
36
|
+
|
|
37
|
+
if results.empty?
|
|
38
|
+
"No results found for: #{query}"
|
|
39
|
+
else
|
|
40
|
+
format_results(query, results, provider)
|
|
41
|
+
end
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
"Search failed (#{detect_provider}): #{e.message}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Pick the best available provider based on env vars
|
|
49
|
+
def detect_provider
|
|
50
|
+
return "tavily" if ENV["TAVILY_API_KEY"]
|
|
51
|
+
return "brave" if ENV["BRAVE_API_KEY"]
|
|
52
|
+
return "serpapi" if ENV["SERPAPI_API_KEY"]
|
|
53
|
+
return "google" if ENV["GOOGLE_SEARCH_API_KEY"] && ENV["GOOGLE_SEARCH_CX"]
|
|
54
|
+
|
|
55
|
+
"duckduckgo"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# ─── DuckDuckGo (no API key, free) ───
|
|
59
|
+
|
|
60
|
+
def search_duckduckgo(query, num_results)
|
|
61
|
+
encoded = CGI.escape(query)
|
|
62
|
+
stdout, _, status = Open3.capture3(
|
|
63
|
+
"curl", "-sL", "--max-time", "15",
|
|
64
|
+
"-H", "User-Agent: Mozilla/5.0 (compatible; RubynCode/1.0)",
|
|
65
|
+
"https://lite.duckduckgo.com/lite/?q=#{encoded}"
|
|
66
|
+
)
|
|
67
|
+
return [] unless status.success?
|
|
68
|
+
|
|
69
|
+
parse_duckduckgo(stdout, num_results)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse_duckduckgo(html, max)
|
|
73
|
+
results = []
|
|
74
|
+
links = html.scan(/<a[^>]+rel="nofollow"[^>]+href="([^"]+)"[^>]*>(.*?)<\/a>/i)
|
|
75
|
+
links = html.scan(/<a[^>]+href="(https?:\/\/(?!lite\.duckduckgo)[^"]+)"[^>]*>(.*?)<\/a>/i) if links.empty?
|
|
76
|
+
snippets = html.scan(/<td[^>]*class="result-snippet"[^>]*>(.*?)<\/td>/im)
|
|
77
|
+
|
|
78
|
+
links.each_with_index do |match, idx|
|
|
79
|
+
break if results.length >= max
|
|
80
|
+
|
|
81
|
+
url = match[0].strip
|
|
82
|
+
title = strip_html(match[1]).strip
|
|
83
|
+
next if url.empty? || title.empty? || url.include?("duckduckgo.com")
|
|
84
|
+
|
|
85
|
+
snippet = snippets[idx] ? strip_html(snippets[idx][0]).strip : ""
|
|
86
|
+
results << { title: title, url: url, snippet: snippet }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
results
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ─── Brave Search (free tier: 2000 queries/mo) ───
|
|
93
|
+
|
|
94
|
+
def search_brave(query, num_results)
|
|
95
|
+
resp = Faraday.get("https://api.search.brave.com/res/v1/web/search") do |req|
|
|
96
|
+
req.params["q"] = query
|
|
97
|
+
req.params["count"] = num_results
|
|
98
|
+
req.headers["Accept"] = "application/json"
|
|
99
|
+
req.headers["Accept-Encoding"] = "gzip"
|
|
100
|
+
req.headers["X-Subscription-Token"] = ENV["BRAVE_API_KEY"]
|
|
101
|
+
req.options.timeout = 15
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
data = JSON.parse(resp.body)
|
|
105
|
+
(data.dig("web", "results") || []).map do |r|
|
|
106
|
+
{ title: r["title"], url: r["url"], snippet: r["description"] || "" }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# ─── Tavily (built for AI agents, free tier: 1000 queries/mo) ───
|
|
111
|
+
|
|
112
|
+
def search_tavily(query, num_results)
|
|
113
|
+
resp = Faraday.post("https://api.tavily.com/search") do |req|
|
|
114
|
+
req.headers["Content-Type"] = "application/json"
|
|
115
|
+
req.body = JSON.generate({
|
|
116
|
+
api_key: ENV["TAVILY_API_KEY"],
|
|
117
|
+
query: query,
|
|
118
|
+
max_results: num_results,
|
|
119
|
+
include_answer: true
|
|
120
|
+
})
|
|
121
|
+
req.options.timeout = 15
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
data = JSON.parse(resp.body)
|
|
125
|
+
results = (data["results"] || []).map do |r|
|
|
126
|
+
{ title: r["title"], url: r["url"], snippet: r["content"] || "" }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Tavily provides a direct answer — prepend it
|
|
130
|
+
if data["answer"]
|
|
131
|
+
results.unshift({ title: "AI Answer", url: "", snippet: data["answer"] })
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
results
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# ─── SerpAPI (free tier: 100 queries/mo) ───
|
|
138
|
+
|
|
139
|
+
def search_serpapi(query, num_results)
|
|
140
|
+
resp = Faraday.get("https://serpapi.com/search.json") do |req|
|
|
141
|
+
req.params["q"] = query
|
|
142
|
+
req.params["num"] = num_results
|
|
143
|
+
req.params["api_key"] = ENV["SERPAPI_API_KEY"]
|
|
144
|
+
req.options.timeout = 15
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
data = JSON.parse(resp.body)
|
|
148
|
+
(data["organic_results"] || []).map do |r|
|
|
149
|
+
{ title: r["title"], url: r["link"], snippet: r["snippet"] || "" }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# ─── Google Custom Search (free tier: 100 queries/day) ───
|
|
154
|
+
|
|
155
|
+
def search_google(query, num_results)
|
|
156
|
+
resp = Faraday.get("https://www.googleapis.com/customsearch/v1") do |req|
|
|
157
|
+
req.params["q"] = query
|
|
158
|
+
req.params["num"] = [num_results, 10].min
|
|
159
|
+
req.params["key"] = ENV["GOOGLE_SEARCH_API_KEY"]
|
|
160
|
+
req.params["cx"] = ENV["GOOGLE_SEARCH_CX"]
|
|
161
|
+
req.options.timeout = 15
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
data = JSON.parse(resp.body)
|
|
165
|
+
(data["items"] || []).map do |r|
|
|
166
|
+
{ title: r["title"], url: r["link"], snippet: r["snippet"] || "" }
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# ─── Shared ───
|
|
171
|
+
|
|
172
|
+
def strip_html(text)
|
|
173
|
+
return "" if text.nil?
|
|
174
|
+
|
|
175
|
+
text.gsub(/<[^>]*>/, "").gsub(/&/, "&").gsub(/</, "<")
|
|
176
|
+
.gsub(/>/, ">").gsub(/"/, '"').gsub(/'/, "'")
|
|
177
|
+
.gsub(/ /, " ").gsub(/\s+/, " ").strip
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def format_results(query, results, provider)
|
|
181
|
+
lines = ["Search results for: #{query} (via #{provider})\n"]
|
|
182
|
+
|
|
183
|
+
results.each_with_index do |result, idx|
|
|
184
|
+
lines << "#{idx + 1}. #{result[:title]}"
|
|
185
|
+
lines << " URL: #{result[:url]}" unless result[:url].empty?
|
|
186
|
+
lines << " #{result[:snippet]}" unless result[:snippet].empty?
|
|
187
|
+
lines << ""
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
truncate(lines.join("\n"), max: 30_000)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
Registry.register(WebSearch)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "registry"
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Tools
|
|
8
|
+
class WriteFile < Base
|
|
9
|
+
TOOL_NAME = "write_file"
|
|
10
|
+
DESCRIPTION = "Writes content to a file. Creates parent directories if needed."
|
|
11
|
+
PARAMETERS = {
|
|
12
|
+
path: { type: :string, required: true, description: "Path to the file to write (relative to project root or absolute)" },
|
|
13
|
+
content: { type: :string, required: true, description: "Content to write to the file" }
|
|
14
|
+
}.freeze
|
|
15
|
+
RISK_LEVEL = :write
|
|
16
|
+
REQUIRES_CONFIRMATION = false
|
|
17
|
+
|
|
18
|
+
def execute(path:, content:)
|
|
19
|
+
resolved = safe_path(path)
|
|
20
|
+
|
|
21
|
+
FileUtils.mkdir_p(File.dirname(resolved))
|
|
22
|
+
bytes = File.write(resolved, content)
|
|
23
|
+
|
|
24
|
+
"Successfully wrote #{bytes} bytes to #{path}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Registry.register(WriteFile)
|
|
29
|
+
end
|
|
30
|
+
end
|