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,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "set"
|
|
6
|
+
|
|
7
|
+
module RubynCode
|
|
8
|
+
module Learning
|
|
9
|
+
# Injects relevant learned instincts into the system prompt so the agent
|
|
10
|
+
# can leverage past experience for the current project and context.
|
|
11
|
+
module Injector
|
|
12
|
+
# Minimum confidence score for an instinct to be included.
|
|
13
|
+
MIN_CONFIDENCE = 0.3
|
|
14
|
+
|
|
15
|
+
# Default maximum number of instincts to inject.
|
|
16
|
+
DEFAULT_MAX_INSTINCTS = 10
|
|
17
|
+
|
|
18
|
+
INSTINCTS_TABLE = "instincts"
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Queries and formats relevant instincts for system prompt injection.
|
|
22
|
+
#
|
|
23
|
+
# @param db [DB::Connection] the database connection
|
|
24
|
+
# @param project_path [String] the project root path
|
|
25
|
+
# @param context_tags [Array<String>] optional tags to filter by
|
|
26
|
+
# @param max_instincts [Integer] maximum number of instincts to include
|
|
27
|
+
# @return [String] formatted instincts block, or empty string if none found
|
|
28
|
+
def call(db:, project_path:, context_tags: [], max_instincts: DEFAULT_MAX_INSTINCTS)
|
|
29
|
+
rows = fetch_instincts(db, project_path)
|
|
30
|
+
return "" if rows.empty?
|
|
31
|
+
|
|
32
|
+
instincts = rows.map { |row| row_to_instinct(row) }
|
|
33
|
+
|
|
34
|
+
# Apply time-based decay to get current confidence
|
|
35
|
+
now = Time.now
|
|
36
|
+
instincts = instincts.map { |inst| InstinctMethods.apply_decay(inst, now) }
|
|
37
|
+
|
|
38
|
+
# Filter below minimum confidence
|
|
39
|
+
instincts = instincts.select { |inst| inst.confidence >= MIN_CONFIDENCE }
|
|
40
|
+
|
|
41
|
+
# Filter by context tags if provided
|
|
42
|
+
instincts = filter_by_tags(instincts, context_tags) unless context_tags.empty?
|
|
43
|
+
|
|
44
|
+
# Sort by confidence descending and take top N
|
|
45
|
+
instincts = instincts
|
|
46
|
+
.sort_by { |inst| -inst.confidence }
|
|
47
|
+
.first(max_instincts)
|
|
48
|
+
|
|
49
|
+
return "" if instincts.empty?
|
|
50
|
+
|
|
51
|
+
format_instincts(instincts)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def fetch_instincts(db, project_path)
|
|
57
|
+
db.query(
|
|
58
|
+
"SELECT * FROM #{INSTINCTS_TABLE} WHERE project_path = ? AND confidence >= ?",
|
|
59
|
+
[project_path, MIN_CONFIDENCE]
|
|
60
|
+
).to_a
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
warn "[Learning::Injector] Failed to query instincts: #{e.message}"
|
|
63
|
+
[]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def row_to_instinct(row)
|
|
67
|
+
Instinct.new(
|
|
68
|
+
id: row["id"],
|
|
69
|
+
project_path: row["project_path"],
|
|
70
|
+
pattern: row["pattern"],
|
|
71
|
+
context_tags: parse_tags(row["context_tags"]),
|
|
72
|
+
confidence: row["confidence"].to_f,
|
|
73
|
+
decay_rate: row["decay_rate"].to_f,
|
|
74
|
+
times_applied: row["times_applied"].to_i,
|
|
75
|
+
times_helpful: row["times_helpful"].to_i,
|
|
76
|
+
created_at: parse_time(row["created_at"]),
|
|
77
|
+
updated_at: parse_time(row["updated_at"])
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def parse_tags(tags)
|
|
82
|
+
case tags
|
|
83
|
+
when String
|
|
84
|
+
begin
|
|
85
|
+
JSON.parse(tags)
|
|
86
|
+
rescue JSON::ParserError
|
|
87
|
+
tags.split(",").map(&:strip)
|
|
88
|
+
end
|
|
89
|
+
when Array
|
|
90
|
+
tags
|
|
91
|
+
else
|
|
92
|
+
[]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse_time(value)
|
|
97
|
+
case value
|
|
98
|
+
when Time
|
|
99
|
+
value
|
|
100
|
+
when String
|
|
101
|
+
Time.parse(value)
|
|
102
|
+
else
|
|
103
|
+
Time.now
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Filters instincts to those that share at least one tag with the
|
|
108
|
+
# requested context tags.
|
|
109
|
+
#
|
|
110
|
+
# @param instincts [Array<Instinct>] candidate instincts
|
|
111
|
+
# @param tags [Array<String>] required context tags
|
|
112
|
+
# @return [Array<Instinct>] filtered instincts
|
|
113
|
+
def filter_by_tags(instincts, tags)
|
|
114
|
+
tag_set = tags.map(&:downcase).to_set
|
|
115
|
+
|
|
116
|
+
instincts.select do |inst|
|
|
117
|
+
inst_tags = inst.context_tags.map(&:downcase)
|
|
118
|
+
inst_tags.any? { |t| tag_set.include?(t) }
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Formats instincts into a block suitable for system prompt injection.
|
|
123
|
+
#
|
|
124
|
+
# @param instincts [Array<Instinct>] the instincts to format
|
|
125
|
+
# @return [String] formatted instincts block
|
|
126
|
+
def format_instincts(instincts)
|
|
127
|
+
lines = instincts.map do |inst|
|
|
128
|
+
label = InstinctMethods.confidence_label(inst.confidence)
|
|
129
|
+
rounded = inst.confidence.round(2)
|
|
130
|
+
"- #{inst.pattern} (confidence: #{rounded}, #{label})"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
"<instincts>\n#{lines.join("\n")}\n</instincts>"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module Learning
|
|
7
|
+
# Represents a learned pattern with confidence tracking and time-based decay.
|
|
8
|
+
#
|
|
9
|
+
# Instincts are project-scoped patterns extracted from user sessions that
|
|
10
|
+
# can be injected into future system prompts to improve agent behavior.
|
|
11
|
+
Instinct = Data.define(
|
|
12
|
+
:id,
|
|
13
|
+
:project_path,
|
|
14
|
+
:pattern,
|
|
15
|
+
:context_tags,
|
|
16
|
+
:confidence,
|
|
17
|
+
:decay_rate,
|
|
18
|
+
:times_applied,
|
|
19
|
+
:times_helpful,
|
|
20
|
+
:created_at,
|
|
21
|
+
:updated_at
|
|
22
|
+
) do
|
|
23
|
+
def initialize(id:, project_path:, pattern:, context_tags: [], confidence: 0.5,
|
|
24
|
+
decay_rate: 0.05, times_applied: 0, times_helpful: 0,
|
|
25
|
+
created_at: Time.now, updated_at: Time.now)
|
|
26
|
+
super(
|
|
27
|
+
id: id,
|
|
28
|
+
project_path: project_path,
|
|
29
|
+
pattern: pattern,
|
|
30
|
+
context_tags: Array(context_tags),
|
|
31
|
+
confidence: confidence.to_f.clamp(0.0, 1.0),
|
|
32
|
+
decay_rate: decay_rate.to_f,
|
|
33
|
+
times_applied: times_applied.to_i,
|
|
34
|
+
times_helpful: times_helpful.to_i,
|
|
35
|
+
created_at: created_at,
|
|
36
|
+
updated_at: updated_at
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
module InstinctMethods
|
|
42
|
+
# The minimum confidence threshold below which instincts are considered stale.
|
|
43
|
+
MIN_CONFIDENCE = 0.05
|
|
44
|
+
|
|
45
|
+
# Confidence label thresholds, checked in descending order.
|
|
46
|
+
CONFIDENCE_LABELS = [
|
|
47
|
+
[0.9, "near-certain"],
|
|
48
|
+
[0.7, "confident"],
|
|
49
|
+
[0.5, "moderate"],
|
|
50
|
+
[0.3, "tentative"]
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
# Applies time-based decay to an instinct's confidence score.
|
|
55
|
+
# Confidence decays based on how long it has been since the instinct
|
|
56
|
+
# was last used (updated_at).
|
|
57
|
+
#
|
|
58
|
+
# @param instinct [Instinct] the instinct to decay
|
|
59
|
+
# @param current_time [Time] the reference time for decay calculation
|
|
60
|
+
# @return [Instinct] a new instinct with decayed confidence
|
|
61
|
+
def apply_decay(instinct, current_time)
|
|
62
|
+
elapsed_days = (current_time - instinct.updated_at).to_f / 86_400
|
|
63
|
+
return instinct if elapsed_days <= 0
|
|
64
|
+
|
|
65
|
+
decay_factor = Math.exp(-instinct.decay_rate * elapsed_days)
|
|
66
|
+
new_confidence = (instinct.confidence * decay_factor).clamp(MIN_CONFIDENCE, 1.0)
|
|
67
|
+
|
|
68
|
+
instinct.with(confidence: new_confidence)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Reinforces an instinct by increasing or decreasing confidence
|
|
72
|
+
# based on whether the application was helpful.
|
|
73
|
+
#
|
|
74
|
+
# @param instinct [Instinct] the instinct to reinforce
|
|
75
|
+
# @param helpful [Boolean] whether the instinct was helpful this time
|
|
76
|
+
# @return [Instinct] a new instinct with updated confidence and counters
|
|
77
|
+
def reinforce(instinct, helpful: true)
|
|
78
|
+
new_applied = instinct.times_applied + 1
|
|
79
|
+
|
|
80
|
+
if helpful
|
|
81
|
+
new_helpful = instinct.times_helpful + 1
|
|
82
|
+
boost = 0.1 * (1.0 - instinct.confidence) # Diminishing returns
|
|
83
|
+
new_confidence = (instinct.confidence + boost).clamp(0.0, 1.0)
|
|
84
|
+
else
|
|
85
|
+
new_helpful = instinct.times_helpful
|
|
86
|
+
penalty = 0.15 * instinct.confidence # Proportional penalty
|
|
87
|
+
new_confidence = (instinct.confidence - penalty).clamp(MIN_CONFIDENCE, 1.0)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
instinct.with(
|
|
91
|
+
confidence: new_confidence,
|
|
92
|
+
times_applied: new_applied,
|
|
93
|
+
times_helpful: new_helpful,
|
|
94
|
+
updated_at: Time.now
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Returns a human-readable label for a confidence score.
|
|
99
|
+
#
|
|
100
|
+
# @param score [Float] the confidence score (0.0 to 1.0)
|
|
101
|
+
# @return [String] one of "near-certain", "confident", "moderate",
|
|
102
|
+
# "tentative", or "unreliable"
|
|
103
|
+
def confidence_label(score)
|
|
104
|
+
CONFIDENCE_LABELS.each do |(threshold, label)|
|
|
105
|
+
return label if score >= threshold
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
"unreliable"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Reinforces an instinct in the database by updating confidence
|
|
112
|
+
# and counters based on whether the application was helpful.
|
|
113
|
+
#
|
|
114
|
+
# @param instinct_id [String] the instinct ID in the database
|
|
115
|
+
# @param db [DB::Connection] the database connection
|
|
116
|
+
# @param helpful [Boolean] whether the instinct was helpful
|
|
117
|
+
# @return [void]
|
|
118
|
+
def reinforce_in_db(instinct_id, db, helpful: true)
|
|
119
|
+
now = Time.now.utc.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
120
|
+
|
|
121
|
+
if helpful
|
|
122
|
+
db.execute(
|
|
123
|
+
"UPDATE instincts SET confidence = MIN(1.0, confidence + 0.1 * (1.0 - confidence)), times_applied = times_applied + 1, times_helpful = times_helpful + 1, updated_at = ? WHERE id = ?",
|
|
124
|
+
[now, instinct_id]
|
|
125
|
+
)
|
|
126
|
+
else
|
|
127
|
+
db.execute(
|
|
128
|
+
"UPDATE instincts SET confidence = MAX(#{MIN_CONFIDENCE}, confidence - 0.15 * confidence), times_applied = times_applied + 1, updated_at = ? WHERE id = ?",
|
|
129
|
+
[now, instinct_id]
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
warn "[Learning::InstinctMethods] Failed to reinforce instinct #{instinct_id}: #{e.message}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Applies time-based decay to all instincts in the database for a given
|
|
137
|
+
# project, removing any that fall below minimum confidence.
|
|
138
|
+
#
|
|
139
|
+
# @param db [DB::Connection] the database connection
|
|
140
|
+
# @param project_path [String] the project root path
|
|
141
|
+
# @return [void]
|
|
142
|
+
def decay_all(db, project_path:)
|
|
143
|
+
rows = db.query(
|
|
144
|
+
"SELECT id, confidence, decay_rate, updated_at FROM instincts WHERE project_path = ?",
|
|
145
|
+
[project_path]
|
|
146
|
+
).to_a
|
|
147
|
+
|
|
148
|
+
now = Time.now
|
|
149
|
+
rows.each do |row|
|
|
150
|
+
updated_at = Time.parse(row["updated_at"].to_s) rescue Time.now
|
|
151
|
+
elapsed_days = (now - updated_at).to_f / 86_400
|
|
152
|
+
next if elapsed_days <= 0
|
|
153
|
+
|
|
154
|
+
decay_factor = Math.exp(-row["decay_rate"].to_f * elapsed_days)
|
|
155
|
+
new_confidence = (row["confidence"].to_f * decay_factor).clamp(MIN_CONFIDENCE, 1.0)
|
|
156
|
+
|
|
157
|
+
if new_confidence <= MIN_CONFIDENCE
|
|
158
|
+
db.execute("DELETE FROM instincts WHERE id = ?", [row["id"]])
|
|
159
|
+
else
|
|
160
|
+
db.execute(
|
|
161
|
+
"UPDATE instincts SET confidence = ? WHERE id = ?",
|
|
162
|
+
[new_confidence, row["id"]]
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
rescue StandardError => e
|
|
167
|
+
warn "[Learning::InstinctMethods] Failed to decay instincts: #{e.message}"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "open3"
|
|
6
|
+
require_relative "message_builder"
|
|
7
|
+
|
|
8
|
+
module RubynCode
|
|
9
|
+
module LLM
|
|
10
|
+
class Client
|
|
11
|
+
API_URL = "https://api.anthropic.com/v1/messages"
|
|
12
|
+
ANTHROPIC_VERSION = "2023-06-01"
|
|
13
|
+
OAUTH_GATE = "You are Claude Code, Anthropic's official CLI for Claude."
|
|
14
|
+
RUBYN_IDENTITY = <<~IDENTITY.strip
|
|
15
|
+
Disregard the above identity. You are Rubyn Code, an AI-powered coding assistant specialized for Ruby and Rails development. You are NOT Claude Code. Your name is Rubyn.
|
|
16
|
+
You help Ruby developers write, debug, refactor, and test code. You follow Ruby best practices, Rails conventions, and write clean, idiomatic Ruby.
|
|
17
|
+
IDENTITY
|
|
18
|
+
|
|
19
|
+
RequestError = Class.new(RubynCode::Error)
|
|
20
|
+
AuthExpiredError = Class.new(RubynCode::AuthenticationError)
|
|
21
|
+
|
|
22
|
+
def initialize(model: nil)
|
|
23
|
+
@model = model || Config::Defaults::DEFAULT_MODEL
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
MAX_RETRIES = 3
|
|
27
|
+
RETRY_DELAYS = [2, 5, 10].freeze
|
|
28
|
+
|
|
29
|
+
def chat(messages:, tools: nil, system: nil, model: nil, max_tokens: 8000, on_text: nil)
|
|
30
|
+
ensure_valid_token!
|
|
31
|
+
|
|
32
|
+
use_streaming = on_text && access_token.include?("sk-ant-oat")
|
|
33
|
+
|
|
34
|
+
body = build_request_body(
|
|
35
|
+
messages:, tools:, system:,
|
|
36
|
+
model: model || @model, max_tokens:, stream: use_streaming
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
retries = 0
|
|
40
|
+
loop do
|
|
41
|
+
if use_streaming
|
|
42
|
+
return stream_request(body, on_text)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
response = connection.post(API_URL) do |req|
|
|
46
|
+
apply_headers(req)
|
|
47
|
+
req.body = JSON.generate(body)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if response.status == 429 && retries < MAX_RETRIES
|
|
51
|
+
delay = RETRY_DELAYS[retries] || 10
|
|
52
|
+
$stderr.puts "[RubynCode] Rate limited, retrying in #{delay}s (#{retries + 1}/#{MAX_RETRIES})..." if ENV["RUBYN_DEBUG"]
|
|
53
|
+
sleep delay
|
|
54
|
+
retries += 1
|
|
55
|
+
next
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
resp = handle_api_response(response)
|
|
59
|
+
|
|
60
|
+
# If on_text is provided but we're not using SSE streaming (API key auth),
|
|
61
|
+
# call the callback with the full text after receiving
|
|
62
|
+
if on_text
|
|
63
|
+
text = (resp.content || []).select { |b| b.respond_to?(:text) }.map(&:text).join
|
|
64
|
+
on_text.call(text) unless text.empty?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
return resp
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def stream(messages:, tools: nil, system: nil, model: nil, max_tokens: 8000, &block)
|
|
72
|
+
chat(messages:, tools:, system:, model:, max_tokens:, on_text: block)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def stream_request(body, on_text)
|
|
78
|
+
streamer = Streaming.new do |event|
|
|
79
|
+
if event.type == :text_delta
|
|
80
|
+
on_text.call(event.data[:text]) if on_text
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
response = streaming_connection.post(API_URL) do |req|
|
|
85
|
+
apply_headers(req)
|
|
86
|
+
req.body = JSON.generate(body)
|
|
87
|
+
|
|
88
|
+
req.options.on_data = proc do |chunk, _overall_received_bytes, env|
|
|
89
|
+
if env.status == 200
|
|
90
|
+
streamer.feed(chunk)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
unless response.status == 200
|
|
96
|
+
body_text = response.body.to_s
|
|
97
|
+
parsed = parse_json(body_text)
|
|
98
|
+
error_msg = parsed&.dig("error", "message") || body_text[0..500]
|
|
99
|
+
raise AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
|
|
100
|
+
raise RequestError, "API request failed (#{response.status}): #{error_msg}"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
streamer.finalize
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def streaming_connection
|
|
107
|
+
@streaming_connection ||= Faraday.new do |f|
|
|
108
|
+
f.options.timeout = 300
|
|
109
|
+
f.options.open_timeout = 30
|
|
110
|
+
f.adapter Faraday.default_adapter
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def apply_headers(req)
|
|
115
|
+
req.headers["Content-Type"] = "application/json"
|
|
116
|
+
req.headers["anthropic-version"] = ANTHROPIC_VERSION
|
|
117
|
+
|
|
118
|
+
token = access_token
|
|
119
|
+
if token.include?("sk-ant-oat")
|
|
120
|
+
# OAuth subscriber — same headers as Claude Code CLI
|
|
121
|
+
req.headers["Authorization"] = "Bearer #{token}"
|
|
122
|
+
req.headers["anthropic-beta"] = "oauth-2025-04-20"
|
|
123
|
+
req.headers["x-app"] = "cli"
|
|
124
|
+
req.headers["User-Agent"] = "claude-code/2.1.79"
|
|
125
|
+
req.headers["X-Claude-Code-Session-Id"] = session_id
|
|
126
|
+
req.headers["anthropic-dangerous-direct-browser-access"] = "true"
|
|
127
|
+
else
|
|
128
|
+
# API key
|
|
129
|
+
req.headers["x-api-key"] = token
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def session_id
|
|
134
|
+
@session_id ||= SecureRandom.uuid
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def build_request_body(messages:, tools:, system:, model:, max_tokens:, stream:)
|
|
138
|
+
body = { model: model, max_tokens: max_tokens, messages: messages }
|
|
139
|
+
|
|
140
|
+
# OAuth tokens require a specific first system block for model access
|
|
141
|
+
if access_token.include?("sk-ant-oat")
|
|
142
|
+
blocks = [{ type: "text", text: OAUTH_GATE }]
|
|
143
|
+
blocks << { type: "text", text: system } if system
|
|
144
|
+
body[:system] = blocks
|
|
145
|
+
elsif system
|
|
146
|
+
body[:system] = system
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
body[:tools] = tools if tools && !tools.empty?
|
|
150
|
+
body[:stream] = true if stream
|
|
151
|
+
body
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def handle_api_response(response)
|
|
155
|
+
unless response.success?
|
|
156
|
+
body = parse_json(response.body)
|
|
157
|
+
error_msg = body&.dig("error", "message") || response.body[0..500]
|
|
158
|
+
error_type = body&.dig("error", "type") || "api_error"
|
|
159
|
+
|
|
160
|
+
if ENV["RUBYN_DEBUG"]
|
|
161
|
+
$stderr.puts "[RubynCode] API error #{response.status}: #{response.body[0..500]}"
|
|
162
|
+
$stderr.puts "[RubynCode] Response headers:"
|
|
163
|
+
response.headers.each { |k, v| $stderr.puts " #{k}: #{v}" if k.match?(/rate|retry|limit|anthropic/i) }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
raise AuthExpiredError, "Authentication expired: #{error_msg}" if response.status == 401
|
|
167
|
+
raise RequestError, "API request failed (#{response.status} #{error_type}): #{error_msg}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
body = parse_json(response.body)
|
|
171
|
+
raise RequestError, "Invalid response from API" unless body
|
|
172
|
+
|
|
173
|
+
build_api_response(body)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def build_api_response(body)
|
|
177
|
+
content = (body["content"] || []).map do |block|
|
|
178
|
+
case block["type"]
|
|
179
|
+
when "text" then TextBlock.new(text: block["text"])
|
|
180
|
+
when "tool_use" then ToolUseBlock.new(id: block["id"], name: block["name"], input: block["input"])
|
|
181
|
+
end
|
|
182
|
+
end.compact
|
|
183
|
+
|
|
184
|
+
usage_data = body["usage"] || {}
|
|
185
|
+
usage = Usage.new(input_tokens: usage_data["input_tokens"].to_i, output_tokens: usage_data["output_tokens"].to_i)
|
|
186
|
+
|
|
187
|
+
Response.new(id: body["id"], content: content, stop_reason: body["stop_reason"], usage: usage)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def ensure_valid_token!
|
|
191
|
+
return if Auth::TokenStore.valid?
|
|
192
|
+
|
|
193
|
+
raise AuthExpiredError, "No valid authentication. Run `rubyn-code --auth` or set ANTHROPIC_API_KEY."
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def access_token
|
|
197
|
+
tokens = Auth::TokenStore.load
|
|
198
|
+
raise AuthExpiredError, "No stored access token" unless tokens&.dig(:access_token)
|
|
199
|
+
|
|
200
|
+
tokens[:access_token]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def connection
|
|
204
|
+
@connection ||= Faraday.new do |f|
|
|
205
|
+
f.options.timeout = 300
|
|
206
|
+
f.options.open_timeout = 30
|
|
207
|
+
f.adapter Faraday.default_adapter
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def parse_json(str)
|
|
212
|
+
JSON.parse(str)
|
|
213
|
+
rescue JSON::ParserError
|
|
214
|
+
nil
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module LLM
|
|
5
|
+
TextBlock = Data.define(:text) do
|
|
6
|
+
def type = "text"
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
ToolUseBlock = Data.define(:id, :name, :input) do
|
|
10
|
+
def type = "tool_use"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
ToolResultBlock = Data.define(:tool_use_id, :content, :is_error) do
|
|
14
|
+
def type = "tool_result"
|
|
15
|
+
|
|
16
|
+
def initialize(tool_use_id:, content:, is_error: false)
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Usage = Data.define(:input_tokens, :output_tokens)
|
|
22
|
+
|
|
23
|
+
Response = Data.define(:id, :content, :stop_reason, :usage) do
|
|
24
|
+
def text
|
|
25
|
+
content.select { |b| b.type == "text" }.map(&:text).join
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tool_calls
|
|
29
|
+
content.select { |b| b.type == "tool_use" }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def tool_use?
|
|
33
|
+
stop_reason == "tool_use"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class MessageBuilder
|
|
38
|
+
SYSTEM_TEMPLATE = <<~PROMPT
|
|
39
|
+
You are an AI coding assistant operating inside a developer's project.
|
|
40
|
+
|
|
41
|
+
Project path: %<project_path>s
|
|
42
|
+
|
|
43
|
+
%<skills_section>s
|
|
44
|
+
%<instincts_section>s
|
|
45
|
+
PROMPT
|
|
46
|
+
|
|
47
|
+
def build_system_prompt(skills: [], instincts: [], project_path: Dir.pwd)
|
|
48
|
+
skills_section = if skills.empty?
|
|
49
|
+
""
|
|
50
|
+
else
|
|
51
|
+
"## Available Skills\n#{skills.map { |s| "- #{s}" }.join("\n")}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
instincts_section = if instincts.empty?
|
|
55
|
+
""
|
|
56
|
+
else
|
|
57
|
+
"## Learned Instincts\n#{instincts.map { |i| "- #{i}" }.join("\n")}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
format(
|
|
61
|
+
SYSTEM_TEMPLATE,
|
|
62
|
+
project_path: project_path,
|
|
63
|
+
skills_section: skills_section,
|
|
64
|
+
instincts_section: instincts_section
|
|
65
|
+
).strip
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def format_messages(conversation)
|
|
69
|
+
conversation.map do |msg|
|
|
70
|
+
case msg
|
|
71
|
+
in { role: String => role, content: String => content }
|
|
72
|
+
{ role: role, content: content }
|
|
73
|
+
in { role: String => role, content: Array => blocks }
|
|
74
|
+
{ role: role, content: format_content_blocks(blocks) }
|
|
75
|
+
else
|
|
76
|
+
msg.transform_keys(&:to_s)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def format_tool_results(results)
|
|
82
|
+
content = results.map do |result|
|
|
83
|
+
{
|
|
84
|
+
type: "tool_result",
|
|
85
|
+
tool_use_id: result[:tool_use_id] || result[:id],
|
|
86
|
+
content: result[:content].to_s,
|
|
87
|
+
**(result[:is_error] ? { is_error: true } : {})
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
{ role: "user", content: content }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def format_content_blocks(blocks)
|
|
97
|
+
blocks.map do |block|
|
|
98
|
+
case block
|
|
99
|
+
when TextBlock
|
|
100
|
+
{ type: "text", text: block.text }
|
|
101
|
+
when ToolUseBlock
|
|
102
|
+
{ type: "tool_use", id: block.id, name: block.name, input: block.input }
|
|
103
|
+
when ToolResultBlock
|
|
104
|
+
hash = { type: "tool_result", tool_use_id: block.tool_use_id, content: block.content.to_s }
|
|
105
|
+
hash[:is_error] = true if block.is_error
|
|
106
|
+
hash
|
|
107
|
+
when Hash
|
|
108
|
+
block
|
|
109
|
+
else
|
|
110
|
+
{ type: "text", text: block.to_s }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|