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,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!("&amp;", "&")
88
+ text.gsub!("&lt;", "<")
89
+ text.gsub!("&gt;", ">")
90
+ text.gsub!("&quot;", '"')
91
+ text.gsub!("&#39;", "'")
92
+ text.gsub!("&nbsp;", " ")
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(/&amp;/, "&").gsub(/&lt;/, "<")
176
+ .gsub(/&gt;/, ">").gsub(/&quot;/, '"').gsub(/&#39;/, "'")
177
+ .gsub(/&nbsp;/, " ").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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ VERSION = "0.1.0"
5
+ end