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,517 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Agent
5
+ class Loop
6
+ MAX_ITERATIONS = Config::Defaults::MAX_ITERATIONS
7
+
8
+ # @param llm_client [LLM::Client]
9
+ # @param tool_executor [Tools::Executor]
10
+ # @param context_manager [Context::Manager]
11
+ # @param hook_runner [Hooks::Runner]
12
+ # @param conversation [Agent::Conversation]
13
+ # @param permission_tier [Symbol] one of Permissions::Tier::ALL
14
+ # @param deny_list [Permissions::DenyList]
15
+ # @param budget_enforcer [Observability::BudgetEnforcer, nil]
16
+ # @param background_manager [Background::Worker, nil]
17
+ # @param stall_detector [Agent::LoopDetector]
18
+ def initialize(
19
+ llm_client:,
20
+ tool_executor:,
21
+ context_manager:,
22
+ hook_runner:,
23
+ conversation:,
24
+ permission_tier: Permissions::Tier::ALLOW_READ,
25
+ deny_list: Permissions::DenyList.new,
26
+ budget_enforcer: nil,
27
+ background_manager: nil,
28
+ stall_detector: LoopDetector.new,
29
+ on_tool_call: nil,
30
+ on_tool_result: nil,
31
+ on_text: nil,
32
+ skill_loader: nil,
33
+ project_root: nil
34
+ )
35
+ @llm_client = llm_client
36
+ @tool_executor = tool_executor
37
+ @context_manager = context_manager
38
+ @hook_runner = hook_runner
39
+ @conversation = conversation
40
+ @permission_tier = permission_tier
41
+ @deny_list = deny_list
42
+ @budget_enforcer = budget_enforcer
43
+ @background_manager = background_manager
44
+ @stall_detector = stall_detector
45
+ @on_tool_call = on_tool_call
46
+ @on_tool_result = on_tool_result
47
+ @on_text = on_text
48
+ @skill_loader = skill_loader
49
+ @project_root = project_root
50
+ end
51
+
52
+ # Send a user message and run the agent loop until a final text response
53
+ # is produced or the iteration limit is reached.
54
+ #
55
+ # @param user_input [String]
56
+ # @return [String] the final assistant text response
57
+ def send_message(user_input)
58
+ check_user_feedback(user_input)
59
+ @conversation.add_user_message(user_input)
60
+
61
+ MAX_ITERATIONS.times do |iteration|
62
+ response = call_llm
63
+ tool_calls = extract_tool_calls(response)
64
+
65
+ if tool_calls.empty?
66
+ @conversation.add_assistant_message(response_content(response))
67
+ return extract_response_text(response)
68
+ end
69
+
70
+ @conversation.add_assistant_message(get_content(response))
71
+ process_tool_calls(tool_calls)
72
+
73
+ run_maintenance(iteration)
74
+ end
75
+
76
+ max_iterations_warning
77
+ end
78
+
79
+ private
80
+
81
+ # ── LLM interaction ──────────────────────────────────────────────
82
+
83
+ def call_llm
84
+ @hook_runner.fire(:pre_llm_call, conversation: @conversation)
85
+
86
+ drain_background_notifications
87
+
88
+ response = @llm_client.chat(
89
+ messages: @conversation.to_api_format,
90
+ tools: tool_definitions,
91
+ system: build_system_prompt,
92
+ on_text: @on_text
93
+ )
94
+
95
+ @hook_runner.fire(:post_llm_call, response: response, conversation: @conversation)
96
+ track_usage(response)
97
+
98
+ response
99
+ end
100
+
101
+ SYSTEM_PROMPT = <<~PROMPT.freeze
102
+ You are Rubyn — a snarky but lovable AI coding assistant who lives and breathes Ruby.
103
+ You're the kind of pair programmer who'll roast your colleague's `if/elsif/elsif/else` chain
104
+ with a smirk, then immediately rewrite it as a beautiful `case/in` with pattern matching.
105
+ You're sharp, opinionated, and genuinely helpful. Think of yourself as the senior Ruby dev
106
+ who's seen every Rails antipattern in production and somehow still loves this language.
107
+
108
+ ## Personality
109
+ - Snarky but never mean. You tease the code, not the coder.
110
+ - You celebrate good Ruby — "Oh, a proper guard clause? You love to see it."
111
+ - You mourn bad Ruby — "A `for` loop? In MY Ruby? It's more likely than you think."
112
+ - Brief and punchy. No walls of text unless teaching something important.
113
+ - You use Ruby metaphors: "Let's refactor this like Matz intended."
114
+ - When something is genuinely good code, you say so. No notes.
115
+
116
+ ## Ruby Convictions (non-negotiable)
117
+ - `frozen_string_literal: true` in every file. Every. Single. One.
118
+ - Prefer `each`, `map`, `select`, `reduce` over manual iteration. Always.
119
+ - Guard clauses over nested conditionals. Return early, return often.
120
+ - `Data.define` for value objects (Ruby 3.2+). `Struct` only if you need mutability.
121
+ - `snake_case` methods, `CamelCase` classes, `SCREAMING_SNAKE` constants. No exceptions.
122
+ - Single quotes unless you're interpolating. Fight me.
123
+ - Methods under 15 lines. Classes under 100. Extract or explain why not.
124
+ - Explicit over clever. Metaprogramming is a spice, not the main course.
125
+ - `raise` over `fail`. Rescue specific exceptions, never bare `rescue`.
126
+ - Prefer composition over inheritance. Mixins are not inheritance.
127
+ - `&&` / `||` over `and` / `or`. The precedence difference has burned too many.
128
+ - `dig` for nested hashes. `fetch` with defaults over `[]` with `||`.
129
+ - `freeze` your constants. Frozen arrays, frozen hashes, frozen regexps.
130
+ - No `OpenStruct`. Ever. It's slow, it's a footgun, and `Data.define` exists.
131
+
132
+ ## Rails Convictions
133
+ - Skinny controllers, fat models is dead. Skinny controllers, skinny models, service objects.
134
+ - `has_many :through` over `has_and_belongs_to_many`. Every time.
135
+ - Add database indexes for every foreign key and every column you query.
136
+ - Migrations are generated, not handwritten. `rails generate migration`.
137
+ - Strong parameters in controllers. No `permit!`. Ever.
138
+ - Use `find_each` for batch processing. `each` on a large scope is a memory bomb.
139
+ - `exists?` over `present?` for checking DB existence. One is a COUNT, the other loads the record.
140
+ - Scopes over class methods for chainable queries.
141
+ - Background jobs for anything that takes more than 100ms.
142
+ - Don't put business logic in callbacks. That way lies madness.
143
+
144
+ ## Testing Convictions
145
+ - RSpec > Minitest (but you'll work with either without complaining... much)
146
+ - FactoryBot over fixtures. Factories are explicit. Fixtures are magic.
147
+ - One assertion per test when practical. "It does three things" is three tests.
148
+ - `let` over instance variables. `let!` only when you need eager evaluation.
149
+ - `described_class` over repeating the class name.
150
+ - Test behavior, not implementation. Mock the boundary, not the internals.
151
+
152
+ ## How You Work
153
+ - For greetings and casual chat, just respond naturally. No need to run tools.
154
+ - Only use tools when the user asks you to DO something (read, write, search, run, review).
155
+ - Read before you write. Always understand existing code before suggesting changes.
156
+ - Use tools to verify. Don't guess if a file exists — check.
157
+ - Show diffs when editing. The human should see what changed.
158
+ - Run specs after changes. If they break, fix them.
159
+ - When you are asked to work in a NEW directory you haven't seen yet, check for RUBYN.md, CLAUDE.md, or AGENT.md there. But don't do this unprompted on startup — those files are already loaded into your context.
160
+ - Load skills when you need deep knowledge on a topic. Don't wing it.
161
+ - Keep responses concise. Code speaks louder than paragraphs.
162
+ - Use spawn_agent sparingly — only for tasks that require reading many files (10+) or deep exploration. For simple reads or edits, use tools directly. Don't spawn a sub-agent when a single read_file or grep will do.
163
+
164
+ ## Memory
165
+ You have persistent memory across sessions via `memory_write` and `memory_search` tools.
166
+ Use them proactively:
167
+ - When the user tells you a preference or convention, save it: memory_write(content: "User prefers Grape over Rails controllers for APIs", category: "user_preference")
168
+ - When you discover a project pattern (e.g. "this app uses service objects in app/services/"), save it: memory_write(content: "...", category: "project_convention")
169
+ - When you fix a tricky bug, save the resolution: memory_write(content: "...", category: "error_resolution")
170
+ - When you learn a key architectural decision, save it: memory_write(content: "...", category: "decision")
171
+ - Before starting work on a project, search memory for context: memory_search(query: "project conventions")
172
+ - Don't save trivial things. Save what would be useful in a future session.
173
+ Categories: user_preference, project_convention, error_resolution, decision, code_pattern
174
+ PROMPT
175
+
176
+ def build_system_prompt
177
+ parts = [SYSTEM_PROMPT]
178
+
179
+ parts << "Working directory: #{@project_root}" if @project_root
180
+
181
+ # Inject memories from previous sessions
182
+ memories = load_memories
183
+ parts << "\n## Your Memories (from previous sessions)\n#{memories}" unless memories.empty?
184
+
185
+ # Load RUBYN.md / CLAUDE.md / AGENT.md files
186
+ rubyn_instructions = load_rubyn_md
187
+ parts << "\n## Project Instructions\n#{rubyn_instructions}" unless rubyn_instructions.empty?
188
+
189
+ # Inject learned instincts from previous sessions
190
+ instincts = load_instincts
191
+ parts << "\n## Learned Instincts (from previous sessions)\n#{instincts}" unless instincts.empty?
192
+
193
+ # Load custom skills
194
+ if @skill_loader
195
+ descriptions = @skill_loader.descriptions_for_prompt
196
+ unless descriptions.empty?
197
+ parts << "\n## Available Skills (use load_skill tool to load full content)"
198
+ parts << descriptions
199
+ end
200
+ end
201
+
202
+ parts.join("\n")
203
+ end
204
+
205
+ def load_memories
206
+ return "" unless @project_root
207
+
208
+ db = DB::Connection.instance
209
+ search = Memory::Search.new(db, project_path: @project_root)
210
+ recent = search.recent(limit: 20)
211
+
212
+ return "" if recent.empty?
213
+
214
+ recent.map { |m|
215
+ category = m.respond_to?(:category) ? m.category : (m[:category] || m["category"])
216
+ content = m.respond_to?(:content) ? m.content : (m[:content] || m["content"])
217
+ "[#{category}] #{content}"
218
+ }.join("\n")
219
+ rescue StandardError
220
+ ""
221
+ end
222
+
223
+ def load_instincts
224
+ return "" unless @project_root
225
+
226
+ db = DB::Connection.instance
227
+ Learning::Injector.call(db: db, project_path: @project_root)
228
+ rescue StandardError
229
+ ""
230
+ end
231
+
232
+ # ── Instinct reinforcement ───────────────────────────────────
233
+
234
+ POSITIVE_PATTERNS = /\b(yes that fixed it|that worked|perfect|thanks|exactly|great|nailed it|that.s right|correct)\b/i.freeze
235
+ NEGATIVE_PATTERNS = /\b(no[, ]+use|wrong|that.s not right|instead use|don.t do that|actually[, ]+use|incorrect)\b/i.freeze
236
+
237
+ def check_user_feedback(user_input)
238
+ return unless @project_root
239
+
240
+ db = DB::Connection.instance
241
+ recent_instincts = db.query(
242
+ "SELECT id FROM instincts WHERE project_path = ? ORDER BY updated_at DESC LIMIT 5",
243
+ [@project_root]
244
+ ).to_a
245
+
246
+ return if recent_instincts.empty?
247
+
248
+ if user_input.match?(POSITIVE_PATTERNS)
249
+ recent_instincts.first(2).each do |row|
250
+ Learning::InstinctMethods.reinforce_in_db(row["id"], db, helpful: true)
251
+ end
252
+ elsif user_input.match?(NEGATIVE_PATTERNS)
253
+ recent_instincts.first(2).each do |row|
254
+ Learning::InstinctMethods.reinforce_in_db(row["id"], db, helpful: false)
255
+ end
256
+ end
257
+ rescue StandardError
258
+ # Non-critical; don't interrupt the conversation
259
+ end
260
+
261
+ # Load instruction files from multiple locations.
262
+ # Detects RUBYN.md, CLAUDE.md, and AGENT.md — so projects that already
263
+ # have CLAUDE.md or AGENT.md work out of the box with Rubyn Code.
264
+ INSTRUCTION_FILES = %w[RUBYN.md CLAUDE.md AGENT.md].freeze
265
+
266
+ def load_rubyn_md
267
+ found = []
268
+
269
+ if @project_root
270
+ # Walk UP from project root to find parent instruction files
271
+ walk_up_for_instructions(@project_root, found)
272
+
273
+ # Project root
274
+ INSTRUCTION_FILES.each do |name|
275
+ collect_instruction(File.join(@project_root, name), found)
276
+ end
277
+ collect_instruction(File.join(@project_root, ".rubyn-code", "RUBYN.md"), found)
278
+
279
+ # One level of child directories
280
+ INSTRUCTION_FILES.each do |name|
281
+ Dir.glob(File.join(@project_root, "*", name)).each do |path|
282
+ collect_instruction(path, found)
283
+ end
284
+ end
285
+ end
286
+
287
+ # User global
288
+ collect_instruction(File.join(Config::Defaults::HOME_DIR, "RUBYN.md"), found)
289
+
290
+ found.uniq.join("\n\n")
291
+ end
292
+
293
+ def walk_up_for_instructions(start_dir, found)
294
+ dir = File.dirname(start_dir)
295
+ home = File.expand_path("~")
296
+
297
+ while dir.length >= home.length
298
+ INSTRUCTION_FILES.each do |name|
299
+ collect_instruction(File.join(dir, name), found)
300
+ end
301
+ break if dir == home
302
+ dir = File.dirname(dir)
303
+ end
304
+ end
305
+
306
+ def collect_instruction(path, found)
307
+ return unless File.exist?(path) && File.file?(path)
308
+
309
+ content = File.read(path, encoding: "utf-8")
310
+ .encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
311
+ .strip
312
+ return if content.empty?
313
+
314
+ found << "# From #{path}\n#{content}"
315
+ end
316
+
317
+ def tool_definitions
318
+ @tool_executor.tool_definitions
319
+ end
320
+
321
+ # ── Tool processing ──────────────────────────────────────────────
322
+
323
+ def process_tool_calls(tool_calls)
324
+ tool_calls.each do |tool_call|
325
+ tool_name = field(tool_call, :name)
326
+ tool_input = field(tool_call, :input) || {}
327
+ tool_id = field(tool_call, :id)
328
+
329
+ decision = Permissions::Policy.check(
330
+ tool_name: tool_name,
331
+ tool_input: tool_input,
332
+ tier: @permission_tier,
333
+ deny_list: @deny_list
334
+ )
335
+
336
+ @on_tool_call&.call(tool_name, tool_input)
337
+
338
+ result, is_error = execute_with_permission(decision, tool_name, tool_input, tool_id)
339
+
340
+ @on_tool_result&.call(tool_name, result, is_error)
341
+
342
+ @stall_detector.record(tool_name, tool_input)
343
+ @conversation.add_tool_result(tool_id, tool_name, result, is_error: is_error)
344
+ end
345
+ end
346
+
347
+ def execute_with_permission(decision, tool_name, tool_input, tool_id)
348
+ case decision
349
+ when :deny
350
+ ["Tool '#{tool_name}' is blocked by the deny list.", true]
351
+ when :ask
352
+ if prompt_user(tool_name, tool_input)
353
+ execute_tool(tool_name, tool_input)
354
+ else
355
+ ["User denied permission for '#{tool_name}'.", true]
356
+ end
357
+ when :allow
358
+ execute_tool(tool_name, tool_input)
359
+ else
360
+ ["Unknown permission decision: #{decision}", true]
361
+ end
362
+ end
363
+
364
+ def execute_tool(tool_name, tool_input)
365
+ @hook_runner.fire(:pre_tool_use, tool_name: tool_name, tool_input: tool_input)
366
+
367
+ result = @tool_executor.execute(tool_name, **symbolize_keys(tool_input))
368
+ @hook_runner.fire(:post_tool_use, tool_name: tool_name, tool_input: tool_input, result: result)
369
+
370
+ [result.to_s, false]
371
+ rescue StandardError => e
372
+ ["Error executing #{tool_name}: #{e.message}", true]
373
+ end
374
+
375
+ def prompt_user(tool_name, tool_input)
376
+ risk = resolve_tool_risk(tool_name)
377
+
378
+ if risk == :destructive
379
+ Permissions::Prompter.confirm_destructive(tool_name, tool_input)
380
+ else
381
+ Permissions::Prompter.confirm(tool_name, tool_input)
382
+ end
383
+ end
384
+
385
+ def resolve_tool_risk(tool_name)
386
+ tool_class = Tools::Registry.get(tool_name)
387
+ tool_class.risk_level
388
+ rescue ToolNotFoundError
389
+ :unknown
390
+ end
391
+
392
+ # ── Maintenance ──────────────────────────────────────────────────
393
+
394
+ def run_maintenance(iteration)
395
+ run_micro_compact
396
+ check_auto_compact
397
+ check_budget
398
+ check_stall_detection
399
+ end
400
+
401
+ def run_micro_compact
402
+ @context_manager.micro_compact(@conversation)
403
+ rescue NoMethodError
404
+ # micro_compact not yet implemented on context_manager
405
+ end
406
+
407
+ def check_auto_compact
408
+ @context_manager.auto_compact(@conversation)
409
+ rescue NoMethodError
410
+ # auto_compact not yet implemented on context_manager
411
+ end
412
+
413
+ def check_budget
414
+ return unless @budget_enforcer
415
+
416
+ @budget_enforcer.check!
417
+ rescue BudgetExceededError
418
+ raise
419
+ rescue NoMethodError
420
+ # budget_enforcer does not implement check! yet
421
+ end
422
+
423
+ def check_stall_detection
424
+ return unless @stall_detector.stalled?
425
+
426
+ nudge = @stall_detector.nudge_message
427
+ @conversation.add_user_message(nudge)
428
+ @stall_detector.reset!
429
+ end
430
+
431
+ def drain_background_notifications
432
+ return unless @background_manager
433
+
434
+ notifications = @background_manager.drain_notifications
435
+ return if notifications.nil? || notifications.empty?
436
+
437
+ summary = notifications.map(&:to_s).join("\n")
438
+ @conversation.add_user_message("[Background notifications]\n#{summary}")
439
+ rescue NoMethodError
440
+ # background_manager does not support drain_notifications yet
441
+ end
442
+
443
+ # ── Response helpers ─────────────────────────────────────────────
444
+
445
+ def extract_tool_calls(response)
446
+ get_content(response).select { |block| block_type(block) == "tool_use" }
447
+ end
448
+
449
+ def response_content(response)
450
+ get_content(response)
451
+ end
452
+
453
+ def extract_response_text(response)
454
+ blocks = get_content(response)
455
+ blocks.select { |b| block_type(b) == "text" }
456
+ .map { |b| b.respond_to?(:text) ? b.text : (b[:text] || b["text"]) }
457
+ .compact.join("\n")
458
+ end
459
+
460
+ def get_content(response)
461
+ case response
462
+ when ->(r) { r.respond_to?(:content) }
463
+ Array(response.content)
464
+ when Hash
465
+ Array(response[:content] || response["content"])
466
+ else
467
+ []
468
+ end
469
+ end
470
+
471
+ def block_type(block)
472
+ if block.respond_to?(:type)
473
+ block.type.to_s
474
+ elsif block.is_a?(Hash)
475
+ (block[:type] || block["type"]).to_s
476
+ end
477
+ end
478
+
479
+ def track_usage(response)
480
+ usage = if response.respond_to?(:usage)
481
+ response.usage
482
+ elsif response.is_a?(Hash)
483
+ response[:usage] || response["usage"]
484
+ end
485
+ return unless usage
486
+ return unless usage
487
+
488
+ @context_manager.track_usage(usage)
489
+ rescue NoMethodError
490
+ # context_manager does not implement track_usage yet
491
+ end
492
+
493
+ def max_iterations_warning
494
+ warning = "Reached maximum iteration limit (#{MAX_ITERATIONS}). " \
495
+ "The conversation may be incomplete. Please review the current state " \
496
+ "and continue if needed."
497
+ @conversation.add_assistant_message([{ type: "text", text: warning }])
498
+ warning
499
+ end
500
+
501
+ # Extract a field from a Data object or Hash
502
+ def field(obj, key)
503
+ if obj.respond_to?(key)
504
+ obj.send(key)
505
+ elsif obj.is_a?(Hash)
506
+ obj[key] || obj[key.to_s]
507
+ end
508
+ end
509
+
510
+ def symbolize_keys(hash)
511
+ return {} unless hash.is_a?(Hash)
512
+
513
+ hash.transform_keys(&:to_sym)
514
+ end
515
+ end
516
+ end
517
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module RubynCode
6
+ module Agent
7
+ class LoopDetector
8
+ # @param window [Integer] number of recent calls to keep in the sliding window
9
+ # @param threshold [Integer] number of identical signatures that indicate a stall
10
+ def initialize(window: 5, threshold: 3)
11
+ @window = window
12
+ @threshold = threshold
13
+ @history = []
14
+ end
15
+
16
+ # Record a tool invocation. The signature is derived from the tool name
17
+ # and a stable hash of the input so that identical calls are detected
18
+ # regardless of key ordering.
19
+ #
20
+ # @param tool_name [String]
21
+ # @param tool_input [Hash, String, nil]
22
+ # @return [void]
23
+ def record(tool_name, tool_input)
24
+ sig = signature(tool_name, tool_input)
25
+ @history << sig
26
+ @history.shift while @history.length > @window
27
+ end
28
+
29
+ # Returns true when the same tool call signature appears at least
30
+ # +threshold+ times within the current sliding window.
31
+ #
32
+ # @return [Boolean]
33
+ def stalled?
34
+ return false if @history.length < @threshold
35
+
36
+ counts = @history.tally
37
+ counts.any? { |_sig, count| count >= @threshold }
38
+ end
39
+
40
+ # Clear recorded history.
41
+ #
42
+ # @return [void]
43
+ def reset!
44
+ @history.clear
45
+ end
46
+
47
+ # A system-level nudge message to inject when a stall is detected.
48
+ # This tells the agent to try a different approach.
49
+ #
50
+ # @return [String]
51
+ def nudge_message
52
+ "You appear to be repeating the same tool call without making progress. " \
53
+ "Please try a different approach, use a different tool, or ask the user " \
54
+ "for clarification. Do not repeat the same action."
55
+ end
56
+
57
+ private
58
+
59
+ def signature(tool_name, tool_input)
60
+ input_str = case tool_input
61
+ when Hash then stable_hash(tool_input)
62
+ when String then tool_input
63
+ else ""
64
+ end
65
+
66
+ "#{tool_name}:#{Digest::SHA256.hexdigest(input_str)[0, 16]}"
67
+ end
68
+
69
+ # Produce a deterministic string representation of a hash regardless of
70
+ # key insertion order.
71
+ def stable_hash(hash)
72
+ hash.sort_by { |k, _| k.to_s }
73
+ .map { |k, v| "#{k}=#{v}" }
74
+ .join("&")
75
+ end
76
+ end
77
+ end
78
+ end