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,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
|