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,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Hooks
5
+ # Default hooks shipped with rubyn-code. These provide core functionality
6
+ # such as cost tracking, tool-call logging, and automatic context compaction.
7
+ module BuiltIn
8
+ # Records cost data after each LLM call using the BudgetEnforcer.
9
+ #
10
+ # Expects the :post_llm_call context to include:
11
+ # - response: the raw API response hash (with :usage or "usage" key)
12
+ # - budget_enforcer: an Observability::BudgetEnforcer instance (optional)
13
+ class CostTrackingHook
14
+ # @param budget_enforcer [Observability::BudgetEnforcer]
15
+ def initialize(budget_enforcer:)
16
+ @budget_enforcer = budget_enforcer
17
+ end
18
+
19
+ # @param response [Hash] the LLM API response
20
+ # @param kwargs [Hash] remaining context (ignored)
21
+ # @return [void]
22
+ def call(response:, **_kwargs)
23
+ return unless @budget_enforcer
24
+
25
+ usage = response[:usage] || response["usage"]
26
+ return unless usage
27
+
28
+ model = response[:model] || response["model"] || "unknown"
29
+ input_tokens = usage[:input_tokens] || usage["input_tokens"] || 0
30
+ output_tokens = usage[:output_tokens] || usage["output_tokens"] || 0
31
+ cache_read = usage[:cache_read_input_tokens] || usage["cache_read_input_tokens"] || 0
32
+ cache_write = usage[:cache_creation_input_tokens] || usage["cache_creation_input_tokens"] || 0
33
+
34
+ @budget_enforcer.record!(
35
+ model: model,
36
+ input_tokens: input_tokens,
37
+ output_tokens: output_tokens,
38
+ cache_read_tokens: cache_read,
39
+ cache_write_tokens: cache_write
40
+ )
41
+ end
42
+ end
43
+
44
+ # Logs tool calls and their results via the formatter.
45
+ #
46
+ # Listens to :pre_tool_use and :post_tool_use events.
47
+ class LoggingHook
48
+ # @param formatter [Output::Formatter]
49
+ def initialize(formatter:)
50
+ @formatter = formatter
51
+ end
52
+
53
+ # Handles both :pre_tool_use and :post_tool_use events.
54
+ #
55
+ # For :pre_tool_use, logs the tool name and input arguments.
56
+ # For :post_tool_use, logs the tool result.
57
+ #
58
+ # @param tool_name [String] name of the tool
59
+ # @param tool_input [Hash] input arguments (for pre_tool_use)
60
+ # @param result [String, nil] tool output (for post_tool_use)
61
+ # @param kwargs [Hash] remaining context
62
+ # @return [nil]
63
+ def call(tool_name:, tool_input: {}, result: nil, **_kwargs)
64
+ if result.nil?
65
+ @formatter.tool_call(tool_name, tool_input)
66
+ else
67
+ @formatter.tool_result(tool_name, result, success: true)
68
+ end
69
+
70
+ nil
71
+ end
72
+ end
73
+
74
+ # Triggers a compaction check after each LLM call to keep the context
75
+ # window within bounds.
76
+ #
77
+ # Expects the :post_llm_call context to include:
78
+ # - conversation: the Agent::Conversation instance
79
+ # - context_manager: a Context::Manager instance (optional)
80
+ class AutoCompactHook
81
+ # @param context_manager [Context::Manager]
82
+ def initialize(context_manager:)
83
+ @context_manager = context_manager
84
+ end
85
+
86
+ # @param conversation [Agent::Conversation] the current conversation
87
+ # @param kwargs [Hash] remaining context (ignored)
88
+ # @return [void]
89
+ def call(conversation: nil, **_kwargs)
90
+ return unless @context_manager && conversation
91
+
92
+ @context_manager.auto_compact(conversation)
93
+ rescue NoMethodError
94
+ # auto_compact not yet available on this context manager
95
+ end
96
+ end
97
+
98
+ class << self
99
+ # Registers all built-in hooks on the given registry.
100
+ #
101
+ # @param registry [Hooks::Registry]
102
+ # @param budget_enforcer [Observability::BudgetEnforcer, nil]
103
+ # @param formatter [Output::Formatter, nil]
104
+ # @param context_manager [Context::Manager, nil]
105
+ # @return [void]
106
+ def register_all!(registry, budget_enforcer: nil, formatter: nil, context_manager: nil)
107
+ if budget_enforcer
108
+ registry.on(:post_llm_call, CostTrackingHook.new(budget_enforcer: budget_enforcer), priority: 10)
109
+ end
110
+
111
+ if formatter
112
+ logging_hook = LoggingHook.new(formatter: formatter)
113
+ registry.on(:pre_tool_use, logging_hook, priority: 50)
114
+ registry.on(:post_tool_use, logging_hook, priority: 50)
115
+ end
116
+
117
+ if context_manager
118
+ registry.on(:post_llm_call, AutoCompactHook.new(context_manager: context_manager), priority: 90)
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module RubynCode
6
+ module Hooks
7
+ # Thread-safe registry for hook callables keyed by event type.
8
+ #
9
+ # Hooks can be registered as blocks or any object responding to #call.
10
+ # Each hook is stored with an optional priority (lower runs first).
11
+ class Registry
12
+ VALID_EVENTS = %i[
13
+ pre_tool_use
14
+ post_tool_use
15
+ pre_llm_call
16
+ post_llm_call
17
+ on_stall
18
+ on_error
19
+ on_session_end
20
+ ].freeze
21
+
22
+ Hook = Data.define(:callable, :priority)
23
+
24
+ include MonitorMixin
25
+
26
+ def initialize
27
+ super() # MonitorMixin
28
+ @hooks = {}
29
+ VALID_EVENTS.each { |event| @hooks[event] = [] }
30
+ end
31
+
32
+ # Registers a hook for the given event.
33
+ #
34
+ # @param event [Symbol] one of VALID_EVENTS
35
+ # @param callable [#call, nil] an object responding to #call, or nil if a block is given
36
+ # @param priority [Integer] execution order (lower runs first, default 100)
37
+ # @yield the hook block (used when callable is nil)
38
+ # @return [void]
39
+ def on(event, callable = nil, priority: 100, &block)
40
+ event = event.to_sym
41
+ validate_event!(event)
42
+
43
+ handler = callable || block
44
+ raise ArgumentError, "A callable or block is required" unless handler
45
+ raise ArgumentError, "Hook must respond to #call" unless handler.respond_to?(:call)
46
+
47
+ synchronize do
48
+ @hooks[event] << Hook.new(callable: handler, priority: priority)
49
+ @hooks[event].sort_by!(&:priority)
50
+ end
51
+ end
52
+
53
+ # Returns an array of callables registered for the given event,
54
+ # ordered by priority (lowest first).
55
+ #
56
+ # @param event [Symbol]
57
+ # @return [Array<#call>]
58
+ def hooks_for(event)
59
+ event = event.to_sym
60
+ synchronize do
61
+ (@hooks[event] || []).map(&:callable)
62
+ end
63
+ end
64
+
65
+ # Clears hooks for a specific event, or all hooks if no event is given.
66
+ #
67
+ # @param event [Symbol, nil]
68
+ # @return [void]
69
+ def clear!(event = nil)
70
+ synchronize do
71
+ if event
72
+ event = event.to_sym
73
+ @hooks[event] = [] if @hooks.key?(event)
74
+ else
75
+ @hooks.each_key { |e| @hooks[e] = [] }
76
+ end
77
+ end
78
+ end
79
+
80
+ # Returns a list of event types that have at least one hook registered.
81
+ #
82
+ # @return [Array<Symbol>]
83
+ def registered_events
84
+ synchronize do
85
+ @hooks.select { |_, hooks| hooks.any? }.keys
86
+ end
87
+ end
88
+
89
+ private
90
+
91
+ def validate_event!(event)
92
+ return if VALID_EVENTS.include?(event)
93
+
94
+ raise ArgumentError,
95
+ "Unknown event #{event.inspect}. Valid events: #{VALID_EVENTS.join(", ")}"
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubynCode
4
+ module Hooks
5
+ # Executes registered hooks for a given event in priority order.
6
+ #
7
+ # Hook execution is defensive: exceptions raised by individual hooks are
8
+ # caught and logged rather than allowed to crash the agent. Special
9
+ # semantics apply to :pre_tool_use (deny gating) and :post_tool_use
10
+ # (output transformation).
11
+ class Runner
12
+ # @param registry [Hooks::Registry] the hook registry to draw from
13
+ def initialize(registry: Registry.new)
14
+ @registry = registry
15
+ end
16
+
17
+ # Fires all hooks for the given event with the supplied context.
18
+ #
19
+ # @param event [Symbol] the event type
20
+ # @param context [Hash] keyword arguments passed to each hook
21
+ # @return [Hash, Object, nil] depends on event semantics:
22
+ # - :pre_tool_use => { deny: true, reason: "..." } if any hook denies, else nil
23
+ # - :post_tool_use => the (possibly transformed) output
24
+ # - all others => nil
25
+ def fire(event, **context)
26
+ hooks = @registry.hooks_for(event)
27
+ return if hooks.empty?
28
+
29
+ case event
30
+ when :pre_tool_use
31
+ fire_pre_tool_use(hooks, context)
32
+ when :post_tool_use
33
+ fire_post_tool_use(hooks, context)
34
+ else
35
+ fire_generic(hooks, event, context)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # For :pre_tool_use, if any hook returns a hash with { deny: true },
42
+ # execution stops and the deny result is returned immediately.
43
+ def fire_pre_tool_use(hooks, context)
44
+ hooks.each do |hook|
45
+ result = safe_call(hook, :pre_tool_use, context)
46
+ next unless result.is_a?(Hash) && result[:deny]
47
+
48
+ return { deny: true, reason: result[:reason] || "Denied by hook" }
49
+ end
50
+
51
+ nil
52
+ end
53
+
54
+ # For :post_tool_use, each hook receives the output from the previous
55
+ # hook (or the original result). This allows hooks to transform output
56
+ # in a pipeline fashion.
57
+ def fire_post_tool_use(hooks, context)
58
+ output = context[:result]
59
+
60
+ hooks.each do |hook|
61
+ transformed = safe_call(hook, :post_tool_use, context.merge(result: output))
62
+ output = transformed unless transformed.nil?
63
+ end
64
+
65
+ output
66
+ end
67
+
68
+ # Generic hook execution: run all hooks, ignore return values.
69
+ def fire_generic(hooks, event, context)
70
+ hooks.each { |hook| safe_call(hook, event, context) }
71
+ nil
72
+ end
73
+
74
+ # Calls a hook safely, catching and logging any exceptions.
75
+ #
76
+ # @param hook [#call] the hook callable
77
+ # @param event [Symbol] the event (for error reporting)
78
+ # @param context [Hash] the context to pass
79
+ # @return [Object, nil] the hook's return value, or nil on error
80
+ def safe_call(hook, event, context)
81
+ hook.call(**context)
82
+ rescue StandardError => e
83
+ warn "[RubynCode::Hooks] Hook error during #{event}: #{e.class}: #{e.message}"
84
+ nil
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module RubynCode
6
+ module Hooks
7
+ class UserHooks
8
+ # Load hooks from YAML config files.
9
+ #
10
+ # Format:
11
+ # pre_tool_use:
12
+ # - tool: bash
13
+ # match: "rm -rf"
14
+ # action: deny
15
+ # reason: "Destructive delete blocked"
16
+ # - tool: write_file
17
+ # path: "db/migrate/**"
18
+ # action: deny
19
+ # reason: "Use rails generate migration"
20
+ # post_tool_use:
21
+ # - tool: write_file
22
+ # action: log
23
+ #
24
+ # @param registry [Hooks::Registry]
25
+ # @param project_root [String] the project root directory
26
+ # @return [void]
27
+ def self.load!(registry, project_root:)
28
+ paths = [
29
+ File.join(project_root, ".rubyn-code", "hooks.yml"),
30
+ File.join(Config::Defaults::HOME_DIR, "hooks.yml")
31
+ ]
32
+
33
+ paths.each do |path|
34
+ next unless File.exist?(path)
35
+
36
+ config = YAML.safe_load_file(path) || {}
37
+ register_hooks(registry, config)
38
+ end
39
+ end
40
+
41
+ class << self
42
+ private
43
+
44
+ def register_hooks(registry, config)
45
+ register_pre_tool_use_hooks(registry, config["pre_tool_use"] || [])
46
+ register_post_tool_use_hooks(registry, config["post_tool_use"] || [])
47
+ end
48
+
49
+ def register_pre_tool_use_hooks(registry, hook_configs)
50
+ hook_configs.each do |hook_config|
51
+ registry.on(:pre_tool_use) do |tool_name:, tool_input:, **|
52
+ next unless matches?(hook_config, tool_name, tool_input)
53
+
54
+ case hook_config["action"]
55
+ when "deny"
56
+ { deny: true, reason: hook_config["reason"] || "Blocked by hooks.yml" }
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ def register_post_tool_use_hooks(registry, hook_configs)
63
+ hook_configs.each do |hook_config|
64
+ registry.on(:post_tool_use) do |tool_name:, result:, **|
65
+ next result unless hook_config["tool"].nil? || hook_config["tool"] == tool_name
66
+
67
+ if hook_config["action"] == "log"
68
+ log_dir = ".rubyn-code"
69
+ FileUtils.mkdir_p(log_dir) unless Dir.exist?(log_dir)
70
+ File.open(File.join(log_dir, "audit.log"), "a") do |f|
71
+ f.puts "[#{Time.now}] #{tool_name}: #{result.to_s[0..200]}"
72
+ end
73
+ end
74
+
75
+ result
76
+ end
77
+ end
78
+ end
79
+
80
+ def matches?(config, tool_name, params)
81
+ return false if config["tool"] && config["tool"] != tool_name
82
+ return false if config["match"] && !params.to_s.include?(config["match"])
83
+ return false if config["path"] && !File.fnmatch?(config["path"], params[:path].to_s)
84
+
85
+ true
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module RubynCode
7
+ module Learning
8
+ # Extracts reusable patterns from session messages using an LLM.
9
+ #
10
+ # After a session, the extractor sends recent conversation history to a
11
+ # cheaper model (Haiku) and asks it to identify patterns that could be
12
+ # useful in future sessions for the same project.
13
+ module Extractor
14
+ # Maximum number of recent messages to analyze.
15
+ MESSAGE_WINDOW = 30
16
+
17
+ # Valid pattern types that the LLM is asked to produce.
18
+ VALID_TYPES = %w[
19
+ error_resolution
20
+ user_correction
21
+ workaround
22
+ debugging_technique
23
+ project_specific
24
+ ].freeze
25
+
26
+ EXTRACTION_PROMPT = <<~PROMPT
27
+ Analyze the following conversation between a developer and an AI coding assistant.
28
+ Extract reusable patterns that could help in future sessions for this project.
29
+
30
+ For each pattern, provide:
31
+ - type: one of #{VALID_TYPES.join(', ')}
32
+ - pattern: a concise description of the learned behavior or fix
33
+ - context_tags: relevant tags (e.g., framework names, error types, file patterns)
34
+ - confidence: initial confidence score between 0.3 and 0.8
35
+
36
+ Respond with a JSON array of objects. If no patterns are found, respond with [].
37
+ Only extract patterns that are genuinely reusable, not one-off fixes.
38
+
39
+ Example response:
40
+ [
41
+ {
42
+ "type": "error_resolution",
43
+ "pattern": "When seeing 'PG::UniqueViolation' on users.email, check for missing unique index migration",
44
+ "context_tags": ["postgresql", "rails", "migration"],
45
+ "confidence": 0.6
46
+ }
47
+ ]
48
+ PROMPT
49
+
50
+ class << self
51
+ # Extracts instinct patterns from a session's message history.
52
+ #
53
+ # @param messages [Array<Hash>] the conversation messages
54
+ # @param llm_client [LLM::Client] the LLM client for extraction
55
+ # @param project_path [String] the project root path
56
+ # @return [Array<Hash>] extracted instinct hashes ready for persistence
57
+ def call(messages, llm_client:, project_path:)
58
+ recent = messages.last(MESSAGE_WINDOW)
59
+ return [] if recent.empty?
60
+
61
+ response = request_extraction(recent, llm_client)
62
+ raw_patterns = parse_response(response)
63
+
64
+ instincts = raw_patterns.filter_map do |raw|
65
+ normalize_pattern(raw, project_path)
66
+ end
67
+
68
+ save_to_db(instincts) unless instincts.empty?
69
+
70
+ instincts
71
+ end
72
+
73
+ private
74
+
75
+ def request_extraction(messages, llm_client)
76
+ # Serialize conversation into a single user message to avoid
77
+ # "must end with user message" errors
78
+ transcript = messages.map { |m|
79
+ role = (m[:role] || m["role"] || "unknown").capitalize
80
+ content = m[:content] || m["content"]
81
+ text = case content
82
+ when String then content
83
+ when Array
84
+ content.filter_map { |b|
85
+ b.respond_to?(:text) ? b.text : (b[:text] || b["text"])
86
+ }.join("\n")
87
+ else content.to_s
88
+ end
89
+ "#{role}: #{text}"
90
+ }.join("\n\n")
91
+
92
+ llm_client.chat(
93
+ messages: [{ role: "user", content: "#{EXTRACTION_PROMPT}\n\nConversation:\n#{transcript}" }],
94
+ max_tokens: 2000
95
+ )
96
+ rescue StandardError => e
97
+ warn "[Learning::Extractor] LLM extraction failed: #{e.message}"
98
+ nil
99
+ end
100
+
101
+ def save_to_db(instincts)
102
+ db = DB::Connection.instance
103
+ now = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
104
+
105
+ instincts.each do |inst|
106
+ db.execute(
107
+ "INSERT INTO instincts (id, project_path, pattern, context_tags, confidence, decay_rate, times_applied, times_helpful, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
108
+ [
109
+ SecureRandom.uuid,
110
+ inst[:project_path],
111
+ inst[:pattern],
112
+ JSON.generate(inst[:context_tags]),
113
+ inst[:confidence],
114
+ inst[:decay_rate],
115
+ inst[:times_applied],
116
+ inst[:times_helpful],
117
+ now,
118
+ now
119
+ ]
120
+ )
121
+ end
122
+ rescue StandardError => e
123
+ warn "[Learning::Extractor] Failed to save instincts: #{e.message}"
124
+ end
125
+
126
+ def parse_response(response)
127
+ return [] if response.nil?
128
+
129
+ text = extract_text(response)
130
+ return [] if text.nil? || text.empty?
131
+
132
+ # Extract JSON array from response, handling markdown code blocks
133
+ json_str = text[/\[.*\]/m]
134
+ return [] if json_str.nil?
135
+
136
+ parsed = JSON.parse(json_str)
137
+ return [] unless parsed.is_a?(Array)
138
+
139
+ parsed
140
+ rescue JSON::ParserError => e
141
+ warn "[Learning::Extractor] Failed to parse extraction response: #{e.message}"
142
+ []
143
+ end
144
+
145
+ def extract_text(response)
146
+ if response.respond_to?(:content)
147
+ block = response.content.find { |b| b.respond_to?(:text) }
148
+ block&.text
149
+ elsif response.is_a?(Hash)
150
+ response.dig("content", 0, "text")
151
+ end
152
+ end
153
+
154
+ def normalize_pattern(raw, project_path)
155
+ type = raw["type"].to_s
156
+ pattern = raw["pattern"].to_s.strip
157
+ context_tags = Array(raw["context_tags"]).map(&:to_s)
158
+ confidence = raw["confidence"].to_f
159
+
160
+ return nil if pattern.empty?
161
+ return nil unless VALID_TYPES.include?(type)
162
+
163
+ confidence = confidence.clamp(0.3, 0.8)
164
+
165
+ {
166
+ project_path: project_path,
167
+ pattern: "[#{type}] #{pattern}",
168
+ context_tags: context_tags,
169
+ confidence: confidence,
170
+ decay_rate: decay_rate_for_type(type),
171
+ times_applied: 0,
172
+ times_helpful: 0
173
+ }
174
+ end
175
+
176
+ # Different pattern types decay at different rates.
177
+ # Project-specific knowledge decays slower; workarounds decay faster.
178
+ def decay_rate_for_type(type)
179
+ case type
180
+ when "project_specific" then 0.02
181
+ when "error_resolution" then 0.03
182
+ when "debugging_technique" then 0.04
183
+ when "user_correction" then 0.05
184
+ when "workaround" then 0.07
185
+ else 0.05
186
+ end
187
+ end
188
+ end
189
+ end
190
+ end
191
+ end