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,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "rouge"
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module CLI
|
|
8
|
+
# Formats streamed text on-the-fly with markdown rendering.
|
|
9
|
+
# Buffers code blocks until they close, then syntax-highlights them.
|
|
10
|
+
# Applies inline formatting (bold, code, headers) as text arrives.
|
|
11
|
+
class StreamFormatter
|
|
12
|
+
def initialize(renderer = nil)
|
|
13
|
+
@pastel = Pastel.new
|
|
14
|
+
@rouge_formatter = Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
|
|
15
|
+
@buffer = +""
|
|
16
|
+
@in_code_block = false
|
|
17
|
+
@code_lang = nil
|
|
18
|
+
@code_buffer = +""
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Feed a chunk of streamed text
|
|
22
|
+
def feed(text)
|
|
23
|
+
@buffer << text
|
|
24
|
+
|
|
25
|
+
# Process complete lines from the buffer
|
|
26
|
+
while (newline_idx = @buffer.index("\n"))
|
|
27
|
+
line = @buffer.slice!(0, newline_idx + 1)
|
|
28
|
+
process_line(line)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Print any remaining partial line (no newline yet) if not in a code block
|
|
32
|
+
unless @in_code_block || @buffer.empty?
|
|
33
|
+
$stdout.print format_inline(@buffer)
|
|
34
|
+
$stdout.flush
|
|
35
|
+
@buffer = +""
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Flush any remaining buffered content
|
|
40
|
+
def flush
|
|
41
|
+
unless @buffer.empty?
|
|
42
|
+
if @in_code_block
|
|
43
|
+
@code_buffer << @buffer
|
|
44
|
+
render_code_block
|
|
45
|
+
else
|
|
46
|
+
$stdout.print format_inline(@buffer)
|
|
47
|
+
end
|
|
48
|
+
@buffer = +""
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Flush unclosed code block
|
|
52
|
+
render_code_block if @in_code_block && !@code_buffer.empty?
|
|
53
|
+
$stdout.flush
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def process_line(line)
|
|
59
|
+
stripped = line.rstrip
|
|
60
|
+
|
|
61
|
+
# Code block toggle
|
|
62
|
+
if stripped.match?(/\A\s*```/)
|
|
63
|
+
if @in_code_block
|
|
64
|
+
# Closing fence — render the buffered code
|
|
65
|
+
render_code_block
|
|
66
|
+
@in_code_block = false
|
|
67
|
+
@code_lang = nil
|
|
68
|
+
else
|
|
69
|
+
# Opening fence
|
|
70
|
+
@in_code_block = true
|
|
71
|
+
@code_lang = stripped.match(/```(\w*)/)[1]
|
|
72
|
+
@code_lang = "ruby" if @code_lang.empty?
|
|
73
|
+
@code_buffer = +""
|
|
74
|
+
$stdout.puts @pastel.dim(" ┌─ #{@code_lang}")
|
|
75
|
+
end
|
|
76
|
+
return
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
if @in_code_block
|
|
80
|
+
@code_buffer << line
|
|
81
|
+
return
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Regular line — format and print
|
|
85
|
+
$stdout.print format_line(line)
|
|
86
|
+
$stdout.flush
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def render_code_block
|
|
90
|
+
return if @code_buffer.empty?
|
|
91
|
+
|
|
92
|
+
lexer = Rouge::Lexer.find(@code_lang || "ruby") || Rouge::Lexers::PlainText.new
|
|
93
|
+
highlighted = @rouge_formatter.format(lexer.lex(@code_buffer))
|
|
94
|
+
border = @pastel.dim(" │ ")
|
|
95
|
+
|
|
96
|
+
highlighted.each_line do |l|
|
|
97
|
+
$stdout.print "#{border}#{l}"
|
|
98
|
+
end
|
|
99
|
+
$stdout.puts @pastel.dim(" └─")
|
|
100
|
+
$stdout.flush
|
|
101
|
+
|
|
102
|
+
@code_buffer = +""
|
|
103
|
+
rescue StandardError
|
|
104
|
+
# Fallback: print unformatted
|
|
105
|
+
@code_buffer.each_line { |l| $stdout.print " #{l}" }
|
|
106
|
+
$stdout.puts
|
|
107
|
+
@code_buffer = +""
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def format_line(line)
|
|
111
|
+
stripped = line.rstrip
|
|
112
|
+
|
|
113
|
+
# Headers
|
|
114
|
+
if stripped.match?(/\A\#{1,6}\s/)
|
|
115
|
+
level = stripped.match(/\A(\#{1,6})\s/)[1].length
|
|
116
|
+
text = stripped.sub(/\A\#{1,6}\s+/, "")
|
|
117
|
+
case level
|
|
118
|
+
when 1 then "#{@pastel.bold.underline(text)}\n"
|
|
119
|
+
when 2 then "\n#{@pastel.bold(text)}\n"
|
|
120
|
+
else "#{@pastel.bold(text)}\n"
|
|
121
|
+
end
|
|
122
|
+
# Bullet lists
|
|
123
|
+
elsif stripped.match?(/\A\s*[-*]\s/)
|
|
124
|
+
indent = stripped.match(/\A(\s*)/)[1]
|
|
125
|
+
content = stripped.sub(/\A\s*[-*]\s+/, "")
|
|
126
|
+
"#{indent} #{@pastel.cyan("•")} #{format_inline(content)}\n"
|
|
127
|
+
# Numbered lists
|
|
128
|
+
elsif stripped.match?(/\A\s*\d+\.\s/)
|
|
129
|
+
indent = stripped.match(/\A(\s*)/)[1]
|
|
130
|
+
num = stripped.match(/(\d+)\./)[1]
|
|
131
|
+
content = stripped.sub(/\A\s*\d+\.\s+/, "")
|
|
132
|
+
"#{indent} #{@pastel.cyan(num + ".")} #{format_inline(content)}\n"
|
|
133
|
+
# Horizontal rules
|
|
134
|
+
elsif stripped.match?(/\A-{3,}\z/)
|
|
135
|
+
"#{@pastel.dim("─" * 40)}\n"
|
|
136
|
+
else
|
|
137
|
+
"#{format_inline(line.chomp)}\n"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def format_inline(text)
|
|
142
|
+
text
|
|
143
|
+
.gsub(/\*\*(.+?)\*\*/) { @pastel.bold(Regexp.last_match(1)) }
|
|
144
|
+
.gsub(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/) { @pastel.italic(Regexp.last_match(1)) }
|
|
145
|
+
.gsub(/`([^`]+)`/) { @pastel.cyan(Regexp.last_match(1)) }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Config
|
|
5
|
+
module Defaults
|
|
6
|
+
HOME_DIR = File.expand_path("~/.rubyn-code")
|
|
7
|
+
CONFIG_FILE = File.join(HOME_DIR, "config.yml")
|
|
8
|
+
DB_FILE = File.join(HOME_DIR, "rubyn_code.db")
|
|
9
|
+
TOKENS_FILE = File.join(HOME_DIR, "tokens.yml")
|
|
10
|
+
SESSIONS_DIR = File.join(HOME_DIR, "sessions")
|
|
11
|
+
MEMORIES_DIR = File.join(HOME_DIR, "memories")
|
|
12
|
+
|
|
13
|
+
DEFAULT_MODEL = "claude-opus-4-6"
|
|
14
|
+
MAX_ITERATIONS = 200
|
|
15
|
+
MAX_SUB_AGENT_ITERATIONS = 30
|
|
16
|
+
MAX_OUTPUT_CHARS = 50_000
|
|
17
|
+
CONTEXT_THRESHOLD_TOKENS = 50_000
|
|
18
|
+
MICRO_COMPACT_KEEP_RECENT = 3
|
|
19
|
+
|
|
20
|
+
POLL_INTERVAL = 5
|
|
21
|
+
IDLE_TIMEOUT = 60
|
|
22
|
+
|
|
23
|
+
SESSION_BUDGET_USD = 5.00
|
|
24
|
+
DAILY_BUDGET_USD = 10.00
|
|
25
|
+
|
|
26
|
+
OAUTH_CLIENT_ID = "rubyn-code"
|
|
27
|
+
OAUTH_REDIRECT_URI = "http://localhost:19275/callback"
|
|
28
|
+
OAUTH_AUTHORIZE_URL = "https://claude.ai/oauth/authorize"
|
|
29
|
+
OAUTH_TOKEN_URL = "https://claude.ai/oauth/token"
|
|
30
|
+
OAUTH_SCOPES = "user:read model:read model:write"
|
|
31
|
+
|
|
32
|
+
DANGEROUS_PATTERNS = [
|
|
33
|
+
"rm -rf /", "sudo rm", "shutdown", "reboot",
|
|
34
|
+
"> /dev/", "mkfs", "dd if=", ":(){:|:&};:"
|
|
35
|
+
].freeze
|
|
36
|
+
|
|
37
|
+
SCRUB_ENV_VARS = %w[
|
|
38
|
+
API_KEY SECRET TOKEN PASSWORD CREDENTIAL
|
|
39
|
+
PRIVATE_KEY ACCESS_KEY SESSION_KEY
|
|
40
|
+
].freeze
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "defaults"
|
|
6
|
+
require_relative "settings"
|
|
7
|
+
|
|
8
|
+
module RubynCode
|
|
9
|
+
module Config
|
|
10
|
+
class ProjectConfig
|
|
11
|
+
class LoadError < StandardError; end
|
|
12
|
+
|
|
13
|
+
PROJECT_DIR_NAME = ".rubyn-code"
|
|
14
|
+
CONFIG_FILENAME = "config.yml"
|
|
15
|
+
|
|
16
|
+
attr_reader :project_root, :config_path, :data
|
|
17
|
+
|
|
18
|
+
# @param project_root [String] the root directory of the project (defaults to pwd)
|
|
19
|
+
# @param global_settings [Settings, nil] global settings to fall back to
|
|
20
|
+
def initialize(project_root: Dir.pwd, global_settings: nil)
|
|
21
|
+
@project_root = File.expand_path(project_root)
|
|
22
|
+
@project_dir = File.join(@project_root, PROJECT_DIR_NAME)
|
|
23
|
+
@config_path = File.join(@project_dir, CONFIG_FILENAME)
|
|
24
|
+
@global_settings = global_settings || Settings.new
|
|
25
|
+
@data = {}
|
|
26
|
+
load!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def get(key, default = nil)
|
|
30
|
+
@data.fetch(key.to_s) { @global_settings.get(key, default) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def set(key, value)
|
|
34
|
+
@data[key.to_s] = value
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Dynamically delegate configurable keys: project-level overrides global
|
|
38
|
+
Settings::CONFIGURABLE_KEYS.each do |key|
|
|
39
|
+
define_method(key) do
|
|
40
|
+
@data.fetch(key.to_s) { @global_settings.public_send(key) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
define_method(:"#{key}=") do |value|
|
|
44
|
+
@data[key.to_s] = value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def save!
|
|
49
|
+
ensure_project_directory!
|
|
50
|
+
File.write(@config_path, YAML.dump(@data))
|
|
51
|
+
rescue Errno::EACCES => e
|
|
52
|
+
raise LoadError, "Permission denied writing project config to #{@config_path}: #{e.message}"
|
|
53
|
+
rescue SystemCallError => e
|
|
54
|
+
raise LoadError, "Failed to save project config to #{@config_path}: #{e.message}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reload!
|
|
58
|
+
load!
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_h
|
|
62
|
+
@global_settings.to_h.merge(@data)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def project_dir_exists?
|
|
66
|
+
File.directory?(@project_dir)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Walks up the directory tree to find the nearest .rubyn-code/config.yml
|
|
70
|
+
# Returns nil if none is found before reaching the filesystem root.
|
|
71
|
+
def self.find_nearest(start_dir: Dir.pwd, global_settings: nil)
|
|
72
|
+
dir = File.expand_path(start_dir)
|
|
73
|
+
|
|
74
|
+
loop do
|
|
75
|
+
candidate = File.join(dir, PROJECT_DIR_NAME, CONFIG_FILENAME)
|
|
76
|
+
if File.exist?(candidate)
|
|
77
|
+
return new(project_root: dir, global_settings: global_settings)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
parent = File.dirname(dir)
|
|
81
|
+
break if parent == dir # filesystem root reached
|
|
82
|
+
|
|
83
|
+
dir = parent
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def ensure_project_directory!
|
|
92
|
+
return if File.directory?(@project_dir)
|
|
93
|
+
|
|
94
|
+
FileUtils.mkdir_p(@project_dir)
|
|
95
|
+
rescue SystemCallError => e
|
|
96
|
+
raise LoadError, "Cannot create project config directory #{@project_dir}: #{e.message}"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def load!
|
|
100
|
+
return unless File.exist?(@config_path)
|
|
101
|
+
|
|
102
|
+
content = File.read(@config_path)
|
|
103
|
+
return if content.strip.empty?
|
|
104
|
+
|
|
105
|
+
parsed = YAML.safe_load(content, permitted_classes: [Symbol])
|
|
106
|
+
|
|
107
|
+
case parsed
|
|
108
|
+
in Hash => h
|
|
109
|
+
@data = h.transform_keys(&:to_s)
|
|
110
|
+
else
|
|
111
|
+
raise LoadError, "Expected a YAML mapping in #{@config_path}, got #{parsed.class}"
|
|
112
|
+
end
|
|
113
|
+
rescue Psych::SyntaxError => e
|
|
114
|
+
raise LoadError, "Malformed YAML in #{@config_path}: #{e.message}"
|
|
115
|
+
rescue Errno::EACCES => e
|
|
116
|
+
raise LoadError, "Permission denied reading #{@config_path}: #{e.message}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "defaults"
|
|
6
|
+
|
|
7
|
+
module RubynCode
|
|
8
|
+
module Config
|
|
9
|
+
class Settings
|
|
10
|
+
class LoadError < StandardError; end
|
|
11
|
+
|
|
12
|
+
CONFIGURABLE_KEYS = %i[
|
|
13
|
+
model max_iterations max_sub_agent_iterations max_output_chars
|
|
14
|
+
context_threshold_tokens micro_compact_keep_recent
|
|
15
|
+
poll_interval idle_timeout
|
|
16
|
+
session_budget_usd daily_budget_usd
|
|
17
|
+
oauth_client_id oauth_redirect_uri oauth_authorize_url
|
|
18
|
+
oauth_token_url oauth_scopes
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
DEFAULT_MAP = {
|
|
22
|
+
model: Defaults::DEFAULT_MODEL,
|
|
23
|
+
max_iterations: Defaults::MAX_ITERATIONS,
|
|
24
|
+
max_sub_agent_iterations: Defaults::MAX_SUB_AGENT_ITERATIONS,
|
|
25
|
+
max_output_chars: Defaults::MAX_OUTPUT_CHARS,
|
|
26
|
+
context_threshold_tokens: Defaults::CONTEXT_THRESHOLD_TOKENS,
|
|
27
|
+
micro_compact_keep_recent: Defaults::MICRO_COMPACT_KEEP_RECENT,
|
|
28
|
+
poll_interval: Defaults::POLL_INTERVAL,
|
|
29
|
+
idle_timeout: Defaults::IDLE_TIMEOUT,
|
|
30
|
+
session_budget_usd: Defaults::SESSION_BUDGET_USD,
|
|
31
|
+
daily_budget_usd: Defaults::DAILY_BUDGET_USD,
|
|
32
|
+
oauth_client_id: Defaults::OAUTH_CLIENT_ID,
|
|
33
|
+
oauth_redirect_uri: Defaults::OAUTH_REDIRECT_URI,
|
|
34
|
+
oauth_authorize_url: Defaults::OAUTH_AUTHORIZE_URL,
|
|
35
|
+
oauth_token_url: Defaults::OAUTH_TOKEN_URL,
|
|
36
|
+
oauth_scopes: Defaults::OAUTH_SCOPES
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
attr_reader :config_path, :data
|
|
40
|
+
|
|
41
|
+
def initialize(config_path: Defaults::CONFIG_FILE)
|
|
42
|
+
@config_path = config_path
|
|
43
|
+
@data = {}
|
|
44
|
+
ensure_home_directory!
|
|
45
|
+
load!
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Define accessor methods for each configurable key
|
|
49
|
+
CONFIGURABLE_KEYS.each do |key|
|
|
50
|
+
define_method(key) do
|
|
51
|
+
@data.fetch(key.to_s, DEFAULT_MAP[key])
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
define_method(:"#{key}=") do |value|
|
|
55
|
+
@data[key.to_s] = value
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def get(key, default = nil)
|
|
60
|
+
sym = key.to_sym
|
|
61
|
+
@data.fetch(key.to_s) { DEFAULT_MAP.fetch(sym, default) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def set(key, value)
|
|
65
|
+
@data[key.to_s] = value
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def save!
|
|
69
|
+
ensure_home_directory!
|
|
70
|
+
File.write(@config_path, YAML.dump(@data))
|
|
71
|
+
File.chmod(0o600, @config_path)
|
|
72
|
+
rescue Errno::EACCES => e
|
|
73
|
+
raise LoadError, "Permission denied writing config to #{@config_path}: #{e.message}"
|
|
74
|
+
rescue SystemCallError => e
|
|
75
|
+
raise LoadError, "Failed to save config to #{@config_path}: #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def reload!
|
|
79
|
+
load!
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def to_h
|
|
83
|
+
DEFAULT_MAP.transform_keys(&:to_s).merge(@data)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def home_dir = Defaults::HOME_DIR
|
|
87
|
+
def db_file = Defaults::DB_FILE
|
|
88
|
+
def tokens_file = Defaults::TOKENS_FILE
|
|
89
|
+
def sessions_dir = Defaults::SESSIONS_DIR
|
|
90
|
+
def memories_dir = Defaults::MEMORIES_DIR
|
|
91
|
+
|
|
92
|
+
def dangerous_patterns = Defaults::DANGEROUS_PATTERNS
|
|
93
|
+
def scrub_env_vars = Defaults::SCRUB_ENV_VARS
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def ensure_home_directory!
|
|
98
|
+
dir = File.dirname(@config_path)
|
|
99
|
+
return if File.directory?(dir)
|
|
100
|
+
|
|
101
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
102
|
+
rescue SystemCallError => e
|
|
103
|
+
raise LoadError, "Cannot create config directory #{dir}: #{e.message}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def load!
|
|
107
|
+
return unless File.exist?(@config_path)
|
|
108
|
+
|
|
109
|
+
content = File.read(@config_path)
|
|
110
|
+
return if content.strip.empty?
|
|
111
|
+
|
|
112
|
+
parsed = YAML.safe_load(content, permitted_classes: [Symbol])
|
|
113
|
+
|
|
114
|
+
case parsed
|
|
115
|
+
in Hash => h
|
|
116
|
+
@data = h.transform_keys(&:to_s)
|
|
117
|
+
else
|
|
118
|
+
raise LoadError, "Expected a YAML mapping in #{@config_path}, got #{parsed.class}"
|
|
119
|
+
end
|
|
120
|
+
rescue Psych::SyntaxError => e
|
|
121
|
+
raise LoadError, "Malformed YAML in #{@config_path}: #{e.message}"
|
|
122
|
+
rescue Errno::EACCES => e
|
|
123
|
+
raise LoadError, "Permission denied reading #{@config_path}: #{e.message}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Context
|
|
8
|
+
# LLM-driven summarization triggered automatically when the context window
|
|
9
|
+
# grows too large. Serializes the conversation tail, asks the LLM to produce
|
|
10
|
+
# a continuity summary, and returns a fresh single-message conversation.
|
|
11
|
+
module AutoCompact
|
|
12
|
+
SUMMARY_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
|
+
# @return [Array<Hash>] new messages array containing only the summary
|
|
30
|
+
def self.call(messages, llm_client:, transcript_dir: nil)
|
|
31
|
+
save_transcript(messages, transcript_dir) if transcript_dir
|
|
32
|
+
|
|
33
|
+
transcript_text = serialize_tail(messages, MAX_TRANSCRIPT_CHARS)
|
|
34
|
+
summary = request_summary(transcript_text, llm_client)
|
|
35
|
+
|
|
36
|
+
[{ role: "user", content: "[Context compacted]\n\n#{summary}" }]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Persists the full conversation to a timestamped JSON file.
|
|
40
|
+
def self.save_transcript(messages, dir)
|
|
41
|
+
FileUtils.mkdir_p(dir)
|
|
42
|
+
timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
|
|
43
|
+
path = File.join(dir, "transcript_#{timestamp}.json")
|
|
44
|
+
File.write(path, JSON.pretty_generate(messages))
|
|
45
|
+
path
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Takes the last +max_chars+ of the JSON-serialized messages.
|
|
49
|
+
def self.serialize_tail(messages, max_chars)
|
|
50
|
+
json = JSON.generate(messages)
|
|
51
|
+
return json if json.length <= max_chars
|
|
52
|
+
|
|
53
|
+
json[-max_chars..]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Sends the transcript to the LLM for summarization.
|
|
57
|
+
def self.request_summary(transcript_text, llm_client)
|
|
58
|
+
summary_messages = [
|
|
59
|
+
{
|
|
60
|
+
role: "user",
|
|
61
|
+
content: "#{SUMMARY_INSTRUCTION}\n\n---\n\n#{transcript_text}"
|
|
62
|
+
}
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
options = {}
|
|
66
|
+
options[:model] = "claude-sonnet-4-20250514" if llm_client.respond_to?(:chat)
|
|
67
|
+
|
|
68
|
+
response = llm_client.chat(messages: summary_messages, **options)
|
|
69
|
+
|
|
70
|
+
case response
|
|
71
|
+
when String then response
|
|
72
|
+
when Hash then response[:content] || response["content"] || response.to_s
|
|
73
|
+
else
|
|
74
|
+
response.respond_to?(:text) ? response.text : response.to_s
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private_class_method :save_transcript, :serialize_tail, :request_summary
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module Context
|
|
7
|
+
# Facade that coordinates the three compaction strategies: micro (every turn),
|
|
8
|
+
# auto (when threshold is exceeded), and manual (user-triggered via /compact).
|
|
9
|
+
class Compactor
|
|
10
|
+
CHARS_PER_TOKEN = 4
|
|
11
|
+
|
|
12
|
+
# @param llm_client [#chat, nil] LLM client for summarization-based compaction
|
|
13
|
+
# @param threshold [Integer] estimated token count that triggers auto-compaction
|
|
14
|
+
# @param transcript_dir [String, nil] directory to persist transcripts before compaction
|
|
15
|
+
def initialize(llm_client:, threshold: 50_000, transcript_dir: nil)
|
|
16
|
+
@llm_client = llm_client
|
|
17
|
+
@threshold = threshold
|
|
18
|
+
@transcript_dir = transcript_dir
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Runs zero-cost micro-compaction on old tool results. Mutates messages
|
|
22
|
+
# in place and returns the count of compacted results.
|
|
23
|
+
#
|
|
24
|
+
# @param messages [Array<Hash>] conversation messages
|
|
25
|
+
# @return [Integer] number of tool results compacted
|
|
26
|
+
def micro_compact!(messages)
|
|
27
|
+
MicroCompact.call(messages)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Runs LLM-driven auto-compaction, replacing the full conversation with a
|
|
31
|
+
# continuity summary. Returns a new messages array.
|
|
32
|
+
#
|
|
33
|
+
# @param messages [Array<Hash>] conversation messages
|
|
34
|
+
# @return [Array<Hash>] compacted messages (single summary message)
|
|
35
|
+
# @raise [RubynCode::Error] if no LLM client is configured
|
|
36
|
+
def auto_compact!(messages)
|
|
37
|
+
ensure_llm_client!
|
|
38
|
+
|
|
39
|
+
AutoCompact.call(
|
|
40
|
+
messages,
|
|
41
|
+
llm_client: @llm_client,
|
|
42
|
+
transcript_dir: @transcript_dir
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Runs LLM-driven manual compaction, optionally guided by a focus prompt.
|
|
47
|
+
# Returns a new messages array.
|
|
48
|
+
#
|
|
49
|
+
# @param messages [Array<Hash>] conversation messages
|
|
50
|
+
# @param focus [String, nil] optional user-supplied focus to guide summarization
|
|
51
|
+
# @return [Array<Hash>] compacted messages (single summary message)
|
|
52
|
+
# @raise [RubynCode::Error] if no LLM client is configured
|
|
53
|
+
def manual_compact!(messages, focus: nil)
|
|
54
|
+
ensure_llm_client!
|
|
55
|
+
|
|
56
|
+
ManualCompact.call(
|
|
57
|
+
messages,
|
|
58
|
+
llm_client: @llm_client,
|
|
59
|
+
transcript_dir: @transcript_dir,
|
|
60
|
+
focus: focus
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Checks whether the estimated token count for the messages exceeds the
|
|
65
|
+
# configured threshold.
|
|
66
|
+
#
|
|
67
|
+
# @param messages [Array<Hash>] conversation messages
|
|
68
|
+
# @return [Boolean]
|
|
69
|
+
def should_auto_compact?(messages)
|
|
70
|
+
estimated_tokens(messages) > @threshold
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def estimated_tokens(messages)
|
|
76
|
+
json = JSON.generate(messages)
|
|
77
|
+
(json.length.to_f / CHARS_PER_TOKEN).ceil
|
|
78
|
+
rescue JSON::GeneratorError
|
|
79
|
+
0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def ensure_llm_client!
|
|
83
|
+
return if @llm_client
|
|
84
|
+
|
|
85
|
+
raise RubynCode::Error, "LLM client is required for summarization-based compaction"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module Context
|
|
7
|
+
# Orchestrates context management for a session. Tracks cumulative token
|
|
8
|
+
# usage from LLM responses and triggers compaction strategies when the
|
|
9
|
+
# estimated context size exceeds the configured threshold.
|
|
10
|
+
class Manager
|
|
11
|
+
CHARS_PER_TOKEN = 4
|
|
12
|
+
|
|
13
|
+
attr_reader :total_input_tokens, :total_output_tokens
|
|
14
|
+
|
|
15
|
+
# @param threshold [Integer] estimated token count that triggers auto-compaction
|
|
16
|
+
def initialize(threshold: 50_000)
|
|
17
|
+
@threshold = threshold
|
|
18
|
+
@total_input_tokens = 0
|
|
19
|
+
@total_output_tokens = 0
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Accumulates token counts from an LLM response usage object.
|
|
23
|
+
#
|
|
24
|
+
# @param usage [LLM::Usage, #input_tokens] usage data from an LLM response
|
|
25
|
+
def track_usage(usage)
|
|
26
|
+
@total_input_tokens += usage.input_tokens.to_i
|
|
27
|
+
@total_output_tokens += usage.output_tokens.to_i
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Rough estimate of token count for a set of messages based on their
|
|
31
|
+
# JSON-serialized character length (~4 chars per token).
|
|
32
|
+
#
|
|
33
|
+
# @param messages [Array<Hash>] conversation messages
|
|
34
|
+
# @return [Integer] estimated token count
|
|
35
|
+
def estimated_tokens(messages)
|
|
36
|
+
json = JSON.generate(messages)
|
|
37
|
+
(json.length.to_f / CHARS_PER_TOKEN).ceil
|
|
38
|
+
rescue JSON::GeneratorError
|
|
39
|
+
0
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns true if the estimated token count exceeds the threshold.
|
|
43
|
+
#
|
|
44
|
+
# @param messages [Array<Hash>] conversation messages
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
def needs_compaction?(messages)
|
|
47
|
+
estimated_tokens(messages) > @threshold
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Runs micro-compaction every turn and auto-compaction when the context
|
|
51
|
+
# exceeds the threshold. Expects a conversation object that responds to
|
|
52
|
+
# #messages and #messages= (or #replace_messages).
|
|
53
|
+
#
|
|
54
|
+
# @param conversation [#messages, #messages=] conversation wrapper
|
|
55
|
+
# @return [void]
|
|
56
|
+
def check_compaction!(conversation)
|
|
57
|
+
messages = conversation.messages
|
|
58
|
+
|
|
59
|
+
MicroCompact.call(messages)
|
|
60
|
+
|
|
61
|
+
return unless needs_compaction?(messages)
|
|
62
|
+
|
|
63
|
+
compactor = Compactor.new(
|
|
64
|
+
llm_client: conversation.respond_to?(:llm_client) ? conversation.llm_client : nil,
|
|
65
|
+
threshold: @threshold
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
new_messages = compactor.auto_compact!(messages)
|
|
69
|
+
apply_compacted_messages(conversation, new_messages)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Resets cumulative token counters to zero.
|
|
73
|
+
#
|
|
74
|
+
# @return [void]
|
|
75
|
+
def reset!
|
|
76
|
+
@total_input_tokens = 0
|
|
77
|
+
@total_output_tokens = 0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def apply_compacted_messages(conversation, new_messages)
|
|
83
|
+
if conversation.respond_to?(:replace_messages)
|
|
84
|
+
conversation.replace_messages(new_messages)
|
|
85
|
+
elsif conversation.respond_to?(:messages=)
|
|
86
|
+
conversation.messages = new_messages
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|