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