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,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Context
|
|
8
|
+
# LLM-driven summarization triggered explicitly by the user or agent via the
|
|
9
|
+
# /compact command. Identical to AutoCompact but supports an optional custom
|
|
10
|
+
# focus prompt so the user can steer what gets preserved.
|
|
11
|
+
module ManualCompact
|
|
12
|
+
BASE_INSTRUCTION = <<~PROMPT
|
|
13
|
+
You are a context compaction assistant. Summarize the following conversation transcript for continuity. Cover exactly three areas:
|
|
14
|
+
|
|
15
|
+
1) **What was accomplished** - completed tasks, files changed, problems solved
|
|
16
|
+
2) **Current state** - what the user/agent is working on right now, any pending actions
|
|
17
|
+
3) **Key decisions made** - architectural choices, user preferences, constraints established
|
|
18
|
+
|
|
19
|
+
Be concise but preserve all details needed to continue the work seamlessly. Use bullet points.
|
|
20
|
+
PROMPT
|
|
21
|
+
|
|
22
|
+
MAX_TRANSCRIPT_CHARS = 80_000
|
|
23
|
+
|
|
24
|
+
# Compacts the conversation by summarizing it through the LLM.
|
|
25
|
+
#
|
|
26
|
+
# @param messages [Array<Hash>] current conversation messages
|
|
27
|
+
# @param llm_client [#chat] an LLM client that responds to #chat
|
|
28
|
+
# @param transcript_dir [String, nil] directory to save full transcript before compaction
|
|
29
|
+
# @param focus [String, nil] optional user-supplied focus prompt to guide summarization
|
|
30
|
+
# @return [Array<Hash>] new messages array containing only the summary
|
|
31
|
+
def self.call(messages, llm_client:, transcript_dir: nil, focus: nil)
|
|
32
|
+
save_transcript(messages, transcript_dir) if transcript_dir
|
|
33
|
+
|
|
34
|
+
transcript_text = serialize_tail(messages, MAX_TRANSCRIPT_CHARS)
|
|
35
|
+
instruction = build_instruction(focus)
|
|
36
|
+
summary = request_summary(transcript_text, instruction, llm_client)
|
|
37
|
+
|
|
38
|
+
[{ role: "user", content: "[Context compacted — manual]\n\n#{summary}" }]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.build_instruction(focus)
|
|
42
|
+
return BASE_INSTRUCTION if focus.nil? || focus.strip.empty?
|
|
43
|
+
|
|
44
|
+
"#{BASE_INSTRUCTION}\nAdditional focus: #{focus}"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.save_transcript(messages, dir)
|
|
48
|
+
FileUtils.mkdir_p(dir)
|
|
49
|
+
timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
|
|
50
|
+
path = File.join(dir, "transcript_manual_#{timestamp}.json")
|
|
51
|
+
File.write(path, JSON.pretty_generate(messages))
|
|
52
|
+
path
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def self.serialize_tail(messages, max_chars)
|
|
56
|
+
json = JSON.generate(messages)
|
|
57
|
+
return json if json.length <= max_chars
|
|
58
|
+
|
|
59
|
+
json[-max_chars..]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.request_summary(transcript_text, instruction, llm_client)
|
|
63
|
+
summary_messages = [
|
|
64
|
+
{
|
|
65
|
+
role: "user",
|
|
66
|
+
content: "#{instruction}\n\n---\n\n#{transcript_text}"
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
options = {}
|
|
71
|
+
options[:model] = "claude-sonnet-4-20250514" if llm_client.respond_to?(:chat)
|
|
72
|
+
|
|
73
|
+
response = llm_client.chat(messages: summary_messages, **options)
|
|
74
|
+
|
|
75
|
+
case response
|
|
76
|
+
when String then response
|
|
77
|
+
when Hash then response[:content] || response["content"] || response.to_s
|
|
78
|
+
else
|
|
79
|
+
response.respond_to?(:text) ? response.text : response.to_s
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private_class_method :build_instruction, :save_transcript,
|
|
84
|
+
:serialize_tail, :request_summary
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Context
|
|
5
|
+
# Zero-cost compression that runs every turn. Replaces old tool results
|
|
6
|
+
# (except the most recent N) with short placeholders to reduce token count
|
|
7
|
+
# without losing conversational continuity.
|
|
8
|
+
module MicroCompact
|
|
9
|
+
PLACEHOLDER_TEMPLATE = "[Previous: used %<tool_name>s]"
|
|
10
|
+
MIN_CONTENT_LENGTH = 100
|
|
11
|
+
|
|
12
|
+
# Mutates +messages+ in place, replacing old tool_result content with
|
|
13
|
+
# compact placeholders.
|
|
14
|
+
#
|
|
15
|
+
# @param messages [Array<Hash>] the conversation messages array
|
|
16
|
+
# @param keep_recent [Integer] number of most-recent tool results to preserve
|
|
17
|
+
# @param preserve_tools [Array<String>] tool names whose results are never compacted
|
|
18
|
+
# @return [Integer] count of compacted tool results
|
|
19
|
+
def self.call(messages, keep_recent: 3, preserve_tools: ["read_file"])
|
|
20
|
+
tool_result_refs = collect_tool_results(messages)
|
|
21
|
+
return 0 if tool_result_refs.size <= keep_recent
|
|
22
|
+
|
|
23
|
+
tool_name_index = build_tool_name_index(messages)
|
|
24
|
+
candidates = tool_result_refs[0..-(keep_recent + 1)]
|
|
25
|
+
compacted = 0
|
|
26
|
+
|
|
27
|
+
candidates.each do |ref|
|
|
28
|
+
block = ref[:block]
|
|
29
|
+
content = extract_content(block)
|
|
30
|
+
next if content.nil? || content.length < MIN_CONTENT_LENGTH
|
|
31
|
+
|
|
32
|
+
tool_name = resolve_tool_name(block, tool_name_index)
|
|
33
|
+
next if preserve_tools.include?(tool_name)
|
|
34
|
+
|
|
35
|
+
placeholder = format(PLACEHOLDER_TEMPLATE, tool_name: tool_name || "tool")
|
|
36
|
+
replace_content!(block, placeholder)
|
|
37
|
+
compacted += 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
compacted
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Collects all tool_result content blocks across user messages, preserving
|
|
44
|
+
# encounter order so the most recent ones can be kept intact.
|
|
45
|
+
#
|
|
46
|
+
# @return [Array<Hash>] each entry has :message, :block, :index keys
|
|
47
|
+
def self.collect_tool_results(messages)
|
|
48
|
+
refs = []
|
|
49
|
+
|
|
50
|
+
messages.each do |msg|
|
|
51
|
+
next unless msg[:role] == "user" && msg[:content].is_a?(Array)
|
|
52
|
+
|
|
53
|
+
msg[:content].each_with_index do |block, idx|
|
|
54
|
+
next unless tool_result_block?(block)
|
|
55
|
+
|
|
56
|
+
refs << { message: msg, block: block, index: idx }
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
refs
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Builds a lookup from tool_use_id to tool name by scanning assistant
|
|
64
|
+
# messages for tool_use blocks.
|
|
65
|
+
#
|
|
66
|
+
# @return [Hash{String => String}]
|
|
67
|
+
def self.build_tool_name_index(messages)
|
|
68
|
+
index = {}
|
|
69
|
+
|
|
70
|
+
messages.each do |msg|
|
|
71
|
+
next unless msg[:role] == "assistant" && msg[:content].is_a?(Array)
|
|
72
|
+
|
|
73
|
+
msg[:content].each do |block|
|
|
74
|
+
case block
|
|
75
|
+
when Hash
|
|
76
|
+
index[block[:id] || block["id"]] = block[:name] || block["name"] if block_type(block) == "tool_use"
|
|
77
|
+
when LLM::ToolUseBlock
|
|
78
|
+
index[block.id] = block.name
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
index
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def self.tool_result_block?(block)
|
|
87
|
+
case block
|
|
88
|
+
when Hash
|
|
89
|
+
block_type(block) == "tool_result"
|
|
90
|
+
when LLM::ToolResultBlock
|
|
91
|
+
true
|
|
92
|
+
else
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def self.block_type(hash)
|
|
98
|
+
hash[:type] || hash["type"]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.extract_content(block)
|
|
102
|
+
case block
|
|
103
|
+
when Hash
|
|
104
|
+
val = block[:content] || block["content"]
|
|
105
|
+
val.is_a?(String) ? val : val.to_s
|
|
106
|
+
when LLM::ToolResultBlock
|
|
107
|
+
block.content.to_s
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.resolve_tool_name(block, index)
|
|
112
|
+
tool_use_id = case block
|
|
113
|
+
when Hash then block[:tool_use_id] || block["tool_use_id"]
|
|
114
|
+
when LLM::ToolResultBlock then block.tool_use_id
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
index[tool_use_id]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.replace_content!(block, placeholder)
|
|
121
|
+
case block
|
|
122
|
+
when Hash
|
|
123
|
+
key = block.key?(:content) ? :content : "content"
|
|
124
|
+
block[key] = placeholder
|
|
125
|
+
end
|
|
126
|
+
# Note: Data.define instances are frozen; for ToolResultBlock objects
|
|
127
|
+
# we rely on messages being stored as hashes in the conversation array.
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private_class_method :collect_tool_results, :build_tool_name_index,
|
|
131
|
+
:tool_result_block?, :block_type, :extract_content,
|
|
132
|
+
:resolve_tool_name, :replace_content!
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sqlite3"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module RubynCode
|
|
8
|
+
module DB
|
|
9
|
+
# Manages a singleton SQLite3 database connection with WAL mode,
|
|
10
|
+
# foreign keys, and thread-safe access.
|
|
11
|
+
class Connection
|
|
12
|
+
include MonitorMixin
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
# Returns the singleton Connection instance, optionally initializing
|
|
16
|
+
# it with the given database path on first call.
|
|
17
|
+
#
|
|
18
|
+
# @param path [String] path to the SQLite3 database file
|
|
19
|
+
# @return [Connection]
|
|
20
|
+
def instance(path = nil)
|
|
21
|
+
@mutex ||= Mutex.new
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
if @instance.nil?
|
|
24
|
+
path ||= Config::Defaults::DB_FILE
|
|
25
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
26
|
+
@instance = new(path)
|
|
27
|
+
end
|
|
28
|
+
@instance
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Executes a write statement (INSERT, UPDATE, DELETE, DDL).
|
|
33
|
+
#
|
|
34
|
+
# @param sql [String] the SQL statement
|
|
35
|
+
# @param params [Array] bind parameters
|
|
36
|
+
# @return [void]
|
|
37
|
+
def execute(sql, params = [])
|
|
38
|
+
instance.execute(sql, params)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Executes a read query and returns rows as hashes.
|
|
42
|
+
#
|
|
43
|
+
# @param sql [String] the SQL query
|
|
44
|
+
# @param params [Array] bind parameters
|
|
45
|
+
# @return [Array<Hash>]
|
|
46
|
+
def query(sql, params = [])
|
|
47
|
+
instance.query(sql, params)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Wraps a block in a database transaction with automatic
|
|
51
|
+
# commit/rollback semantics. Supports nested calls via SAVEPOINTs.
|
|
52
|
+
#
|
|
53
|
+
# @yield the block to execute within the transaction
|
|
54
|
+
# @return [Object] the return value of the block
|
|
55
|
+
def transaction(&block)
|
|
56
|
+
instance.transaction(&block)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Tears down the singleton instance. Intended for test cleanup.
|
|
60
|
+
#
|
|
61
|
+
# @return [void]
|
|
62
|
+
def reset!
|
|
63
|
+
@mutex ||= Mutex.new
|
|
64
|
+
@mutex.synchronize do
|
|
65
|
+
if @instance
|
|
66
|
+
@instance.close
|
|
67
|
+
@instance = nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @param path [String] path to the SQLite3 database file
|
|
74
|
+
def initialize(path)
|
|
75
|
+
super() # MonitorMixin
|
|
76
|
+
@path = path
|
|
77
|
+
@db = SQLite3::Database.new(path)
|
|
78
|
+
@transaction_depth = 0
|
|
79
|
+
configure_connection
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Executes a write statement with bind parameters.
|
|
83
|
+
#
|
|
84
|
+
# @param sql [String]
|
|
85
|
+
# @param params [Array]
|
|
86
|
+
# @return [void]
|
|
87
|
+
def execute(sql, params = [])
|
|
88
|
+
synchronize do
|
|
89
|
+
@db.execute(sql, params)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Executes a read query and returns all matching rows.
|
|
94
|
+
#
|
|
95
|
+
# @param sql [String]
|
|
96
|
+
# @param params [Array]
|
|
97
|
+
# @return [Array<Hash>]
|
|
98
|
+
def query(sql, params = [])
|
|
99
|
+
synchronize do
|
|
100
|
+
@db.execute(sql, params)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Wraps a block in a transaction. Nested calls use SAVEPOINTs
|
|
105
|
+
# to avoid SQLite "cannot start a transaction within a transaction" errors.
|
|
106
|
+
#
|
|
107
|
+
# @yield the block to execute
|
|
108
|
+
# @return [Object] the block's return value
|
|
109
|
+
def transaction
|
|
110
|
+
synchronize do
|
|
111
|
+
if @transaction_depth.zero?
|
|
112
|
+
begin_top_level_transaction
|
|
113
|
+
else
|
|
114
|
+
begin_savepoint
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
@transaction_depth += 1
|
|
118
|
+
begin
|
|
119
|
+
result = yield
|
|
120
|
+
if @transaction_depth == 1
|
|
121
|
+
@db.execute("COMMIT")
|
|
122
|
+
else
|
|
123
|
+
@db.execute("RELEASE SAVEPOINT sp_#{@transaction_depth}")
|
|
124
|
+
end
|
|
125
|
+
result
|
|
126
|
+
rescue StandardError => e
|
|
127
|
+
if @transaction_depth == 1
|
|
128
|
+
@db.execute("ROLLBACK")
|
|
129
|
+
else
|
|
130
|
+
@db.execute("ROLLBACK TO SAVEPOINT sp_#{@transaction_depth}")
|
|
131
|
+
@db.execute("RELEASE SAVEPOINT sp_#{@transaction_depth}")
|
|
132
|
+
end
|
|
133
|
+
raise e
|
|
134
|
+
ensure
|
|
135
|
+
@transaction_depth -= 1
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Closes the underlying database connection.
|
|
141
|
+
#
|
|
142
|
+
# @return [void]
|
|
143
|
+
def close
|
|
144
|
+
synchronize do
|
|
145
|
+
@db.close unless @db.closed?
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Returns whether the connection is open.
|
|
150
|
+
#
|
|
151
|
+
# @return [Boolean]
|
|
152
|
+
def open?
|
|
153
|
+
!@db.closed?
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def configure_connection
|
|
159
|
+
@db.results_as_hash = true
|
|
160
|
+
@db.execute("PRAGMA journal_mode = WAL")
|
|
161
|
+
@db.execute("PRAGMA foreign_keys = ON")
|
|
162
|
+
@db.execute("PRAGMA busy_timeout = 5000")
|
|
163
|
+
@db.execute("PRAGMA synchronous = NORMAL")
|
|
164
|
+
@db.execute("PRAGMA cache_size = -20000") # 20 MB
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def begin_top_level_transaction
|
|
168
|
+
@db.execute("BEGIN IMMEDIATE")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def begin_savepoint
|
|
172
|
+
@db.execute("SAVEPOINT sp_#{@transaction_depth + 1}")
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module DB
|
|
5
|
+
# Reads SQL migration files from db/migrations/, tracks applied versions
|
|
6
|
+
# in a schema_migrations table, and applies new migrations in order.
|
|
7
|
+
class Migrator
|
|
8
|
+
# @return [String] absolute path to the migrations directory
|
|
9
|
+
MIGRATIONS_DIR = File.expand_path("../../../db/migrations", __dir__).freeze
|
|
10
|
+
|
|
11
|
+
# @param connection [Connection] the database connection to migrate
|
|
12
|
+
def initialize(connection)
|
|
13
|
+
@connection = connection
|
|
14
|
+
ensure_schema_migrations_table
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Applies all pending migrations in version order.
|
|
18
|
+
#
|
|
19
|
+
# @return [Array<Integer>] list of newly applied migration versions
|
|
20
|
+
def migrate!
|
|
21
|
+
pending = pending_migrations
|
|
22
|
+
return [] if pending.empty?
|
|
23
|
+
|
|
24
|
+
applied = []
|
|
25
|
+
pending.each do |version, path|
|
|
26
|
+
apply_migration(version, path)
|
|
27
|
+
applied << version
|
|
28
|
+
end
|
|
29
|
+
applied
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Returns migration versions that have not yet been applied.
|
|
33
|
+
#
|
|
34
|
+
# @return [Array<Array(Integer, String)>] pairs of [version, file_path]
|
|
35
|
+
def pending_migrations
|
|
36
|
+
applied = applied_versions
|
|
37
|
+
available_migrations.reject { |version, _| applied.include?(version) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns the set of already-applied migration versions.
|
|
41
|
+
#
|
|
42
|
+
# @return [Set<Integer>]
|
|
43
|
+
def applied_versions
|
|
44
|
+
rows = @connection.query(
|
|
45
|
+
"SELECT version FROM schema_migrations ORDER BY version"
|
|
46
|
+
).to_a
|
|
47
|
+
rows.map { |row| row["version"] }.to_set
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns the current schema version (highest applied migration).
|
|
51
|
+
#
|
|
52
|
+
# @return [Integer, nil]
|
|
53
|
+
def current_version
|
|
54
|
+
row = @connection.query(
|
|
55
|
+
"SELECT MAX(version) AS max_version FROM schema_migrations"
|
|
56
|
+
).to_a.first
|
|
57
|
+
row && row["max_version"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Lists all available migration files sorted by version.
|
|
61
|
+
#
|
|
62
|
+
# @return [Array<Array(Integer, String)>] pairs of [version, file_path]
|
|
63
|
+
def available_migrations
|
|
64
|
+
pattern = File.join(MIGRATIONS_DIR, "*.sql")
|
|
65
|
+
Dir.glob(pattern)
|
|
66
|
+
.map { |path| parse_migration_file(path) }
|
|
67
|
+
.compact
|
|
68
|
+
.sort_by(&:first)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def ensure_schema_migrations_table
|
|
74
|
+
@connection.execute(<<~SQL)
|
|
75
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
76
|
+
version INTEGER PRIMARY KEY,
|
|
77
|
+
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
|
78
|
+
)
|
|
79
|
+
SQL
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def apply_migration(version, path)
|
|
83
|
+
sql = File.read(path)
|
|
84
|
+
@connection.transaction do
|
|
85
|
+
# Execute each statement separately (SQLite doesn't support multi-statement execute)
|
|
86
|
+
split_statements(sql).each do |statement|
|
|
87
|
+
@connection.execute(statement)
|
|
88
|
+
end
|
|
89
|
+
@connection.execute(
|
|
90
|
+
"INSERT INTO schema_migrations (version) VALUES (?)", [version]
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Splits a SQL file into individual statements, handling semicolons
|
|
96
|
+
# inside string literals and ignoring empty/comment-only fragments.
|
|
97
|
+
#
|
|
98
|
+
# @param sql [String]
|
|
99
|
+
# @return [Array<String>]
|
|
100
|
+
def split_statements(sql)
|
|
101
|
+
statements = []
|
|
102
|
+
current = +""
|
|
103
|
+
in_block = false
|
|
104
|
+
|
|
105
|
+
sql.each_line do |line|
|
|
106
|
+
stripped = line.strip
|
|
107
|
+
|
|
108
|
+
# Track BEGIN/END blocks (e.g., triggers)
|
|
109
|
+
in_block = true if stripped.match?(/\bBEGIN\b/i) && !stripped.match?(/\ABEGIN\s+(IMMEDIATE|DEFERRED|EXCLUSIVE)/i)
|
|
110
|
+
current << line
|
|
111
|
+
|
|
112
|
+
if in_block
|
|
113
|
+
if stripped.match?(/\bEND\b\s*;?\s*$/i)
|
|
114
|
+
in_block = false
|
|
115
|
+
statements << current.strip.chomp(";")
|
|
116
|
+
current = +""
|
|
117
|
+
end
|
|
118
|
+
elsif stripped.end_with?(";")
|
|
119
|
+
stmt = current.strip.chomp(";").strip
|
|
120
|
+
statements << stmt unless stmt.empty? || (stmt.match?(/\A\s*--/) && !stmt.include?("\n"))
|
|
121
|
+
current = +""
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Handle any remaining content
|
|
126
|
+
remainder = current.strip.chomp(";").strip
|
|
127
|
+
statements << remainder unless remainder.empty?
|
|
128
|
+
|
|
129
|
+
statements
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Extracts the version number and name from a migration filename.
|
|
133
|
+
#
|
|
134
|
+
# @param path [String]
|
|
135
|
+
# @return [Array(Integer, String), nil]
|
|
136
|
+
def parse_migration_file(path)
|
|
137
|
+
basename = File.basename(path, ".sql")
|
|
138
|
+
match = basename.match(/\A(\d+)_/)
|
|
139
|
+
return nil unless match
|
|
140
|
+
|
|
141
|
+
version = match[1].to_i
|
|
142
|
+
[version, path]
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module DB
|
|
5
|
+
# Provides schema introspection helpers and version checking
|
|
6
|
+
# for the database.
|
|
7
|
+
class Schema
|
|
8
|
+
# @param connection [Connection] the database connection
|
|
9
|
+
def initialize(connection)
|
|
10
|
+
@connection = connection
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Returns the current schema version (highest applied migration).
|
|
14
|
+
#
|
|
15
|
+
# @return [Integer, nil] the version number, or nil if no migrations applied
|
|
16
|
+
def current_version
|
|
17
|
+
row = @connection.query(
|
|
18
|
+
"SELECT MAX(version) AS max_version FROM schema_migrations"
|
|
19
|
+
).to_a.first
|
|
20
|
+
row && row["max_version"]
|
|
21
|
+
rescue StandardError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Returns all applied migration versions in order.
|
|
26
|
+
#
|
|
27
|
+
# @return [Array<Integer>]
|
|
28
|
+
def applied_versions
|
|
29
|
+
@connection.query(
|
|
30
|
+
"SELECT version FROM schema_migrations ORDER BY version"
|
|
31
|
+
).to_a.map { |row| row["version"] }
|
|
32
|
+
rescue StandardError
|
|
33
|
+
[]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Checks whether a specific migration version has been applied.
|
|
37
|
+
#
|
|
38
|
+
# @param version [Integer]
|
|
39
|
+
# @return [Boolean]
|
|
40
|
+
def version_applied?(version)
|
|
41
|
+
rows = @connection.query(
|
|
42
|
+
"SELECT 1 FROM schema_migrations WHERE version = ?", [version]
|
|
43
|
+
).to_a
|
|
44
|
+
!rows.empty?
|
|
45
|
+
rescue StandardError
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns a list of table names in the database (excluding internal SQLite tables).
|
|
50
|
+
#
|
|
51
|
+
# @return [Array<String>]
|
|
52
|
+
def tables
|
|
53
|
+
@connection.query(
|
|
54
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' " \
|
|
55
|
+
"AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
|
56
|
+
).to_a.map { |row| row["name"] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Returns column information for the given table.
|
|
60
|
+
#
|
|
61
|
+
# @param table_name [String]
|
|
62
|
+
# @return [Array<Hash>] each hash has keys: cid, name, type, notnull, dflt_value, pk
|
|
63
|
+
def columns(table_name)
|
|
64
|
+
@connection.query("PRAGMA table_info(#{quote_identifier(table_name)})").to_a
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns index information for the given table.
|
|
68
|
+
#
|
|
69
|
+
# @param table_name [String]
|
|
70
|
+
# @return [Array<Hash>]
|
|
71
|
+
def indexes(table_name)
|
|
72
|
+
@connection.query("PRAGMA index_list(#{quote_identifier(table_name)})").to_a
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Checks whether a given table exists in the database.
|
|
76
|
+
#
|
|
77
|
+
# @param table_name [String]
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
def table_exists?(table_name)
|
|
80
|
+
rows = @connection.query(
|
|
81
|
+
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
82
|
+
[table_name]
|
|
83
|
+
).to_a
|
|
84
|
+
!rows.empty?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Returns true if the schema is up to date with all available migrations.
|
|
88
|
+
#
|
|
89
|
+
# @param migrator [Migrator]
|
|
90
|
+
# @return [Boolean]
|
|
91
|
+
def up_to_date?(migrator)
|
|
92
|
+
migrator.pending_migrations.empty?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# Safely quotes a SQL identifier to prevent injection.
|
|
98
|
+
#
|
|
99
|
+
# @param name [String]
|
|
100
|
+
# @return [String]
|
|
101
|
+
def quote_identifier(name)
|
|
102
|
+
"\"#{name.gsub('"', '""')}\""
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|