rubyn-code 0.1.0 → 0.2.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 +4 -4
- data/README.md +269 -467
- data/db/migrations/009_create_teams.sql +6 -6
- data/db/migrations/011_fix_mailbox_messages_columns.rb +35 -0
- data/db/migrations/012_expand_mailbox_message_types.rb +37 -0
- data/exe/rubyn-code +1 -1
- data/lib/rubyn_code/agent/RUBYN.md +17 -0
- data/lib/rubyn_code/agent/conversation.rb +68 -19
- data/lib/rubyn_code/agent/loop.rb +312 -54
- data/lib/rubyn_code/agent/loop_detector.rb +6 -6
- data/lib/rubyn_code/auth/RUBYN.md +19 -0
- data/lib/rubyn_code/auth/oauth.rb +40 -35
- data/lib/rubyn_code/auth/server.rb +16 -12
- data/lib/rubyn_code/auth/token_store.rb +22 -22
- data/lib/rubyn_code/autonomous/RUBYN.md +14 -0
- data/lib/rubyn_code/autonomous/daemon.rb +115 -79
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -8
- data/lib/rubyn_code/autonomous/task_claimer.rb +11 -11
- data/lib/rubyn_code/background/RUBYN.md +13 -0
- data/lib/rubyn_code/background/notifier.rb +0 -2
- data/lib/rubyn_code/background/worker.rb +60 -15
- data/lib/rubyn_code/cli/RUBYN.md +30 -0
- data/lib/rubyn_code/cli/app.rb +85 -9
- data/lib/rubyn_code/cli/commands/RUBYN.md +133 -0
- data/lib/rubyn_code/cli/commands/base.rb +53 -0
- data/lib/rubyn_code/cli/commands/budget.rb +24 -0
- data/lib/rubyn_code/cli/commands/clear.rb +16 -0
- data/lib/rubyn_code/cli/commands/compact.rb +21 -0
- data/lib/rubyn_code/cli/commands/context.rb +44 -0
- data/lib/rubyn_code/cli/commands/context_info.rb +56 -0
- data/lib/rubyn_code/cli/commands/cost.rb +23 -0
- data/lib/rubyn_code/cli/commands/diff.rb +30 -0
- data/lib/rubyn_code/cli/commands/doctor.rb +112 -0
- data/lib/rubyn_code/cli/commands/help.rb +41 -0
- data/lib/rubyn_code/cli/commands/model.rb +37 -0
- data/lib/rubyn_code/cli/commands/plan.rb +22 -0
- data/lib/rubyn_code/cli/commands/quit.rb +17 -0
- data/lib/rubyn_code/cli/commands/registry.rb +64 -0
- data/lib/rubyn_code/cli/commands/resume.rb +51 -0
- data/lib/rubyn_code/cli/commands/review.rb +26 -0
- data/lib/rubyn_code/cli/commands/skill.rb +32 -0
- data/lib/rubyn_code/cli/commands/spawn.rb +24 -0
- data/lib/rubyn_code/cli/commands/tasks.rb +32 -0
- data/lib/rubyn_code/cli/commands/tokens.rb +76 -0
- data/lib/rubyn_code/cli/commands/undo.rb +17 -0
- data/lib/rubyn_code/cli/commands/version.rb +16 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +129 -0
- data/lib/rubyn_code/cli/input_handler.rb +20 -23
- data/lib/rubyn_code/cli/renderer.rb +25 -27
- data/lib/rubyn_code/cli/repl.rb +161 -194
- data/lib/rubyn_code/cli/setup.rb +117 -0
- data/lib/rubyn_code/cli/spinner.rb +40 -40
- data/lib/rubyn_code/cli/stream_formatter.rb +29 -28
- data/lib/rubyn_code/cli/version_check.rb +94 -0
- data/lib/rubyn_code/config/RUBYN.md +14 -0
- data/lib/rubyn_code/config/defaults.rb +28 -19
- data/lib/rubyn_code/config/project_config.rb +7 -9
- data/lib/rubyn_code/config/settings.rb +3 -3
- data/lib/rubyn_code/context/RUBYN.md +20 -0
- data/lib/rubyn_code/context/auto_compact.rb +7 -7
- data/lib/rubyn_code/context/compactor.rb +2 -2
- data/lib/rubyn_code/context/context_collapse.rb +45 -0
- data/lib/rubyn_code/context/manager.rb +20 -3
- data/lib/rubyn_code/context/manual_compact.rb +7 -7
- data/lib/rubyn_code/context/micro_compact.rb +12 -12
- data/lib/rubyn_code/db/RUBYN.md +40 -0
- data/lib/rubyn_code/db/connection.rb +13 -13
- data/lib/rubyn_code/db/migrator.rb +67 -27
- data/lib/rubyn_code/db/schema.rb +6 -6
- data/lib/rubyn_code/debug.rb +74 -0
- data/lib/rubyn_code/hooks/RUBYN.md +17 -0
- data/lib/rubyn_code/hooks/built_in.rb +9 -9
- data/lib/rubyn_code/hooks/registry.rb +5 -5
- data/lib/rubyn_code/hooks/runner.rb +1 -1
- data/lib/rubyn_code/hooks/user_hooks.rb +16 -16
- data/lib/rubyn_code/learning/RUBYN.md +16 -0
- data/lib/rubyn_code/learning/extractor.rb +22 -22
- data/lib/rubyn_code/learning/injector.rb +17 -18
- data/lib/rubyn_code/learning/instinct.rb +18 -14
- data/lib/rubyn_code/llm/RUBYN.md +15 -0
- data/lib/rubyn_code/llm/client.rb +121 -55
- data/lib/rubyn_code/llm/message_builder.rb +19 -15
- data/lib/rubyn_code/llm/streaming.rb +80 -50
- data/lib/rubyn_code/mcp/RUBYN.md +21 -0
- data/lib/rubyn_code/mcp/client.rb +25 -24
- data/lib/rubyn_code/mcp/config.rb +7 -7
- data/lib/rubyn_code/mcp/sse_transport.rb +27 -26
- data/lib/rubyn_code/mcp/stdio_transport.rb +22 -19
- data/lib/rubyn_code/mcp/tool_bridge.rb +32 -32
- data/lib/rubyn_code/memory/RUBYN.md +17 -0
- data/lib/rubyn_code/memory/models.rb +3 -3
- data/lib/rubyn_code/memory/search.rb +17 -17
- data/lib/rubyn_code/memory/session_persistence.rb +49 -34
- data/lib/rubyn_code/memory/store.rb +17 -17
- data/lib/rubyn_code/observability/RUBYN.md +19 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +16 -15
- data/lib/rubyn_code/observability/cost_calculator.rb +3 -3
- data/lib/rubyn_code/observability/token_counter.rb +1 -1
- data/lib/rubyn_code/observability/usage_reporter.rb +35 -35
- data/lib/rubyn_code/output/RUBYN.md +11 -0
- data/lib/rubyn_code/output/diff_renderer.rb +6 -6
- data/lib/rubyn_code/output/formatter.rb +4 -4
- data/lib/rubyn_code/permissions/RUBYN.md +17 -0
- data/lib/rubyn_code/permissions/prompter.rb +8 -8
- data/lib/rubyn_code/protocols/RUBYN.md +14 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +1 -1
- data/lib/rubyn_code/protocols/plan_approval.rb +9 -9
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +9 -11
- data/lib/rubyn_code/skills/RUBYN.md +19 -0
- data/lib/rubyn_code/skills/catalog.rb +7 -7
- data/lib/rubyn_code/skills/document.rb +15 -15
- data/lib/rubyn_code/skills/loader.rb +6 -8
- data/lib/rubyn_code/sub_agents/RUBYN.md +12 -0
- data/lib/rubyn_code/sub_agents/runner.rb +15 -15
- data/lib/rubyn_code/sub_agents/summarizer.rb +1 -1
- data/lib/rubyn_code/tasks/RUBYN.md +13 -0
- data/lib/rubyn_code/tasks/dag.rb +12 -16
- data/lib/rubyn_code/tasks/manager.rb +24 -24
- data/lib/rubyn_code/tasks/models.rb +4 -4
- data/lib/rubyn_code/teams/RUBYN.md +14 -0
- data/lib/rubyn_code/teams/mailbox.rb +38 -18
- data/lib/rubyn_code/teams/manager.rb +19 -19
- data/lib/rubyn_code/teams/teammate.rb +3 -4
- data/lib/rubyn_code/tools/RUBYN.md +38 -0
- data/lib/rubyn_code/tools/background_run.rb +9 -11
- data/lib/rubyn_code/tools/base.rb +54 -3
- data/lib/rubyn_code/tools/bash.rb +16 -34
- data/lib/rubyn_code/tools/bundle_add.rb +10 -12
- data/lib/rubyn_code/tools/bundle_install.rb +9 -11
- data/lib/rubyn_code/tools/compact.rb +10 -9
- data/lib/rubyn_code/tools/db_migrate.rb +17 -15
- data/lib/rubyn_code/tools/edit_file.rb +12 -12
- data/lib/rubyn_code/tools/executor.rb +9 -4
- data/lib/rubyn_code/tools/git_commit.rb +29 -34
- data/lib/rubyn_code/tools/git_diff.rb +17 -18
- data/lib/rubyn_code/tools/git_log.rb +17 -19
- data/lib/rubyn_code/tools/git_status.rb +18 -20
- data/lib/rubyn_code/tools/glob.rb +7 -9
- data/lib/rubyn_code/tools/grep.rb +11 -9
- data/lib/rubyn_code/tools/load_skill.rb +7 -7
- data/lib/rubyn_code/tools/memory_search.rb +13 -12
- data/lib/rubyn_code/tools/memory_write.rb +14 -12
- data/lib/rubyn_code/tools/rails_generate.rb +16 -16
- data/lib/rubyn_code/tools/read_file.rb +8 -7
- data/lib/rubyn_code/tools/read_inbox.rb +5 -5
- data/lib/rubyn_code/tools/registry.rb +2 -2
- data/lib/rubyn_code/tools/review_pr.rb +55 -55
- data/lib/rubyn_code/tools/run_specs.rb +20 -19
- data/lib/rubyn_code/tools/schema.rb +9 -11
- data/lib/rubyn_code/tools/send_message.rb +10 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +51 -23
- data/lib/rubyn_code/tools/spawn_teammate.rb +21 -21
- data/lib/rubyn_code/tools/task.rb +28 -28
- data/lib/rubyn_code/tools/web_fetch.rb +46 -31
- data/lib/rubyn_code/tools/web_search.rb +64 -66
- data/lib/rubyn_code/tools/write_file.rb +7 -6
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +136 -105
- metadata +94 -21
|
@@ -1,50 +1,50 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'tty-spinner'
|
|
4
4
|
|
|
5
5
|
module RubynCode
|
|
6
6
|
module CLI
|
|
7
7
|
class Spinner
|
|
8
8
|
THINKING_MESSAGES = [
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
9
|
+
'Massaging the hash...',
|
|
10
|
+
'Refactoring in my head...',
|
|
11
|
+
'Consulting Matz...',
|
|
12
|
+
'Freezing strings...',
|
|
13
|
+
'Monkey-patching reality...',
|
|
14
|
+
'Yielding to the block...',
|
|
15
|
+
'Enumerating possibilities...',
|
|
16
|
+
'Injecting dependencies...',
|
|
17
|
+
'Guard clause-ing my thoughts...',
|
|
18
|
+
'Sharpening the gems...',
|
|
19
|
+
'Duck typing furiously...',
|
|
20
|
+
'Reducing complexity...',
|
|
21
|
+
'Mapping it out...',
|
|
22
|
+
'Selecting the right approach...',
|
|
23
|
+
'Running the mental specs...',
|
|
24
|
+
'Composing a module...',
|
|
25
|
+
'Memoizing the answer...',
|
|
26
|
+
'Digging through the hash...',
|
|
27
|
+
'Pattern matching on this...',
|
|
28
|
+
'Raising my standards...',
|
|
29
|
+
'Rescuing the situation...',
|
|
30
|
+
'Benchmarking my thoughts...',
|
|
31
|
+
'Sending :think to self...',
|
|
32
|
+
'Evaluating the proc...',
|
|
33
|
+
'Opening the eigenclass...',
|
|
34
|
+
'Calling .new on an idea...',
|
|
35
|
+
'Plucking the good bits...',
|
|
36
|
+
'Finding each solution...',
|
|
37
|
+
'Requiring more context...',
|
|
38
|
+
'Bundling my thoughts...'
|
|
39
39
|
].freeze
|
|
40
40
|
|
|
41
41
|
SUB_AGENT_MESSAGES = [
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
'Sub-agent is spelunking...',
|
|
43
|
+
'Agent exploring the codebase...',
|
|
44
|
+
'Reading all the things...',
|
|
45
|
+
'Sub-agent doing the legwork...',
|
|
46
|
+
'Agent grepping through files...',
|
|
47
|
+
'Dispatching the intern...'
|
|
48
48
|
].freeze
|
|
49
49
|
|
|
50
50
|
def initialize
|
|
@@ -62,7 +62,7 @@ module RubynCode
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def start_sub_agent(tool_count = 0)
|
|
65
|
-
msg = if tool_count
|
|
65
|
+
msg = if tool_count.positive?
|
|
66
66
|
"#{SUB_AGENT_MESSAGES.sample} (#{tool_count} tools)"
|
|
67
67
|
else
|
|
68
68
|
SUB_AGENT_MESSAGES.sample
|
|
@@ -77,12 +77,12 @@ module RubynCode
|
|
|
77
77
|
start(message)
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
-
def success(message =
|
|
80
|
+
def success(message = 'Done')
|
|
81
81
|
@spinner&.success("(#{message})")
|
|
82
82
|
@spinner = nil
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
-
def error(message =
|
|
85
|
+
def error(message = 'Failed')
|
|
86
86
|
@spinner&.error("(#{message})")
|
|
87
87
|
@spinner = nil
|
|
88
88
|
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require 'pastel'
|
|
4
|
+
require 'rouge'
|
|
5
5
|
|
|
6
6
|
module RubynCode
|
|
7
7
|
module CLI
|
|
@@ -9,13 +9,13 @@ module RubynCode
|
|
|
9
9
|
# Buffers code blocks until they close, then syntax-highlights them.
|
|
10
10
|
# Applies inline formatting (bold, code, headers) as text arrives.
|
|
11
11
|
class StreamFormatter
|
|
12
|
-
def initialize(
|
|
12
|
+
def initialize(_renderer = nil)
|
|
13
13
|
@pastel = Pastel.new
|
|
14
14
|
@rouge_formatter = Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
|
|
15
|
-
@buffer = +
|
|
15
|
+
@buffer = +''
|
|
16
16
|
@in_code_block = false
|
|
17
17
|
@code_lang = nil
|
|
18
|
-
@code_buffer = +
|
|
18
|
+
@code_buffer = +''
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
# Feed a chunk of streamed text
|
|
@@ -29,11 +29,11 @@ module RubynCode
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
# Print any remaining partial line (no newline yet) if not in a code block
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
return if @in_code_block || @buffer.empty?
|
|
33
|
+
|
|
34
|
+
$stdout.print format_inline(@buffer)
|
|
35
|
+
$stdout.flush
|
|
36
|
+
@buffer = +''
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
# Flush any remaining buffered content
|
|
@@ -45,7 +45,7 @@ module RubynCode
|
|
|
45
45
|
else
|
|
46
46
|
$stdout.print format_inline(@buffer)
|
|
47
47
|
end
|
|
48
|
-
@buffer = +
|
|
48
|
+
@buffer = +''
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
# Flush unclosed code block
|
|
@@ -69,8 +69,8 @@ module RubynCode
|
|
|
69
69
|
# Opening fence
|
|
70
70
|
@in_code_block = true
|
|
71
71
|
@code_lang = stripped.match(/```(\w*)/)[1]
|
|
72
|
-
@code_lang =
|
|
73
|
-
@code_buffer = +
|
|
72
|
+
@code_lang = 'ruby' if @code_lang.empty?
|
|
73
|
+
@code_buffer = +''
|
|
74
74
|
$stdout.puts @pastel.dim(" ┌─ #{@code_lang}")
|
|
75
75
|
end
|
|
76
76
|
return
|
|
@@ -89,50 +89,51 @@ module RubynCode
|
|
|
89
89
|
def render_code_block
|
|
90
90
|
return if @code_buffer.empty?
|
|
91
91
|
|
|
92
|
-
lexer = Rouge::Lexer.find(@code_lang ||
|
|
92
|
+
lexer = Rouge::Lexer.find(@code_lang || 'ruby') || Rouge::Lexers::PlainText.new
|
|
93
93
|
highlighted = @rouge_formatter.format(lexer.lex(@code_buffer))
|
|
94
|
-
border = @pastel.dim(
|
|
94
|
+
border = @pastel.dim(' │ ')
|
|
95
95
|
|
|
96
96
|
highlighted.each_line do |l|
|
|
97
97
|
$stdout.print "#{border}#{l}"
|
|
98
98
|
end
|
|
99
|
-
$stdout.puts @pastel.dim(
|
|
99
|
+
$stdout.puts @pastel.dim(' └─')
|
|
100
100
|
$stdout.flush
|
|
101
101
|
|
|
102
|
-
@code_buffer = +
|
|
102
|
+
@code_buffer = +''
|
|
103
103
|
rescue StandardError
|
|
104
104
|
# Fallback: print unformatted
|
|
105
105
|
@code_buffer.each_line { |l| $stdout.print " #{l}" }
|
|
106
106
|
$stdout.puts
|
|
107
|
-
@code_buffer = +
|
|
107
|
+
@code_buffer = +''
|
|
108
108
|
end
|
|
109
109
|
|
|
110
110
|
def format_line(line)
|
|
111
111
|
stripped = line.rstrip
|
|
112
112
|
|
|
113
113
|
# Headers
|
|
114
|
-
|
|
114
|
+
case stripped
|
|
115
|
+
when /\A\#{1,6}\s/
|
|
115
116
|
level = stripped.match(/\A(\#{1,6})\s/)[1].length
|
|
116
|
-
text = stripped.sub(/\A\#{1,6}\s+/,
|
|
117
|
+
text = stripped.sub(/\A\#{1,6}\s+/, '')
|
|
117
118
|
case level
|
|
118
119
|
when 1 then "#{@pastel.bold.underline(text)}\n"
|
|
119
120
|
when 2 then "\n#{@pastel.bold(text)}\n"
|
|
120
121
|
else "#{@pastel.bold(text)}\n"
|
|
121
122
|
end
|
|
122
123
|
# Bullet lists
|
|
123
|
-
|
|
124
|
+
when /\A\s*[-*]\s/
|
|
124
125
|
indent = stripped.match(/\A(\s*)/)[1]
|
|
125
|
-
content = stripped.sub(/\A\s*[-*]\s+/,
|
|
126
|
-
"#{indent} #{@pastel.cyan(
|
|
126
|
+
content = stripped.sub(/\A\s*[-*]\s+/, '')
|
|
127
|
+
"#{indent} #{@pastel.cyan('•')} #{format_inline(content)}\n"
|
|
127
128
|
# Numbered lists
|
|
128
|
-
|
|
129
|
+
when /\A\s*\d+\.\s/
|
|
129
130
|
indent = stripped.match(/\A(\s*)/)[1]
|
|
130
131
|
num = stripped.match(/(\d+)\./)[1]
|
|
131
|
-
content = stripped.sub(/\A\s*\d+\.\s+/,
|
|
132
|
-
"#{indent} #{@pastel.cyan(num
|
|
132
|
+
content = stripped.sub(/\A\s*\d+\.\s+/, '')
|
|
133
|
+
"#{indent} #{@pastel.cyan("#{num}.")} #{format_inline(content)}\n"
|
|
133
134
|
# Horizontal rules
|
|
134
|
-
|
|
135
|
-
"#{@pastel.dim(
|
|
135
|
+
when /\A-{3,}\z/
|
|
136
|
+
"#{@pastel.dim('─' * 40)}\n"
|
|
136
137
|
else
|
|
137
138
|
"#{format_inline(line.chomp)}\n"
|
|
138
139
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'faraday'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module CLI
|
|
8
|
+
# Non-blocking version check against RubyGems.
|
|
9
|
+
# Runs in a background thread so it never delays startup.
|
|
10
|
+
# Caches the result for 24 hours to avoid hammering the API.
|
|
11
|
+
class VersionCheck
|
|
12
|
+
RUBYGEMS_API = 'https://rubygems.org/api/v1/versions/rubyn-code/latest.json'
|
|
13
|
+
CACHE_FILE = File.join(Config::Defaults::HOME_DIR, '.version_check')
|
|
14
|
+
CACHE_TTL = 86_400 # 24 hours
|
|
15
|
+
|
|
16
|
+
def initialize(renderer:)
|
|
17
|
+
@renderer = renderer
|
|
18
|
+
@thread = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Kicks off a background check. Call `notify` later to display results.
|
|
22
|
+
def start
|
|
23
|
+
return if ENV['RUBYN_NO_UPDATE_CHECK']
|
|
24
|
+
|
|
25
|
+
@thread = Thread.new { check }
|
|
26
|
+
@thread.abort_on_exception = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Waits briefly for the check to finish and prints a message if outdated.
|
|
30
|
+
def notify(timeout: 2)
|
|
31
|
+
return unless @thread
|
|
32
|
+
|
|
33
|
+
@thread.join(timeout)
|
|
34
|
+
return unless @result
|
|
35
|
+
|
|
36
|
+
return unless newer?(@result, RubynCode::VERSION)
|
|
37
|
+
|
|
38
|
+
@renderer.warning(
|
|
39
|
+
"Update available: #{RubynCode::VERSION} -> #{@result} " \
|
|
40
|
+
'(gem install rubyn-code)'
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def check
|
|
47
|
+
cached = read_cache
|
|
48
|
+
if cached
|
|
49
|
+
@result = cached
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
conn = Faraday.new do |f|
|
|
54
|
+
f.options.timeout = 5
|
|
55
|
+
f.options.open_timeout = 3
|
|
56
|
+
end
|
|
57
|
+
response = conn.get(RUBYGEMS_API)
|
|
58
|
+
return unless response.success?
|
|
59
|
+
|
|
60
|
+
data = JSON.parse(response.body)
|
|
61
|
+
latest = data['version']
|
|
62
|
+
return unless latest
|
|
63
|
+
return unless latest.match?(/\A\d+\.\d+/)
|
|
64
|
+
return unless Gem::Version.correct?(latest)
|
|
65
|
+
|
|
66
|
+
write_cache(latest)
|
|
67
|
+
@result = latest
|
|
68
|
+
rescue StandardError
|
|
69
|
+
# Silent — never interrupt startup for a version check
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def newer?(remote, local)
|
|
73
|
+
Gem::Version.new(remote) > Gem::Version.new(local)
|
|
74
|
+
rescue ArgumentError
|
|
75
|
+
false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def read_cache
|
|
79
|
+
return nil unless File.exist?(CACHE_FILE)
|
|
80
|
+
return nil if (Time.now - File.mtime(CACHE_FILE)) > CACHE_TTL
|
|
81
|
+
|
|
82
|
+
File.read(CACHE_FILE).strip
|
|
83
|
+
rescue StandardError
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def write_cache(version)
|
|
88
|
+
File.write(CACHE_FILE, version)
|
|
89
|
+
rescue StandardError
|
|
90
|
+
# Best effort
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Config Layer
|
|
2
|
+
|
|
3
|
+
Application settings with per-project overrides.
|
|
4
|
+
|
|
5
|
+
## Classes
|
|
6
|
+
|
|
7
|
+
- **`Settings`** — Main configuration module. Merges defaults with user overrides from
|
|
8
|
+
`~/.rubyn-code/config.yml` and project-level `.rubyn-code.yml`.
|
|
9
|
+
|
|
10
|
+
- **`Defaults`** — Frozen constants for all default values: `MAX_ITERATIONS`, model names,
|
|
11
|
+
token limits, budget caps, permission tiers, etc.
|
|
12
|
+
|
|
13
|
+
- **`ProjectConfig`** — Loads project-specific configuration from `.rubyn-code.yml` in the
|
|
14
|
+
working directory. Supports custom permission tiers, deny lists, and hook definitions.
|
|
@@ -3,19 +3,28 @@
|
|
|
3
3
|
module RubynCode
|
|
4
4
|
module Config
|
|
5
5
|
module Defaults
|
|
6
|
-
HOME_DIR = File.expand_path(
|
|
7
|
-
CONFIG_FILE = File.join(HOME_DIR,
|
|
8
|
-
DB_FILE = File.join(HOME_DIR,
|
|
9
|
-
TOKENS_FILE = File.join(HOME_DIR,
|
|
10
|
-
SESSIONS_DIR = File.join(HOME_DIR,
|
|
11
|
-
MEMORIES_DIR = File.join(HOME_DIR,
|
|
12
|
-
|
|
13
|
-
DEFAULT_MODEL =
|
|
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
14
|
MAX_ITERATIONS = 200
|
|
15
|
-
MAX_SUB_AGENT_ITERATIONS =
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
MAX_SUB_AGENT_ITERATIONS = 200
|
|
16
|
+
MAX_EXPLORE_AGENT_ITERATIONS = 200
|
|
17
|
+
|
|
18
|
+
# Output token management (3-tier recovery, matches Claude Code)
|
|
19
|
+
CAPPED_MAX_OUTPUT_TOKENS = 8_000 # Default cap — keeps prompt cache efficient
|
|
20
|
+
ESCALATED_MAX_OUTPUT_TOKENS = 32_000 # Silent escalation on first max_tokens hit
|
|
21
|
+
MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3 # Multi-turn recovery attempts after escalation
|
|
22
|
+
|
|
23
|
+
MAX_OUTPUT_CHARS = 10_000
|
|
24
|
+
MAX_TOOL_RESULT_CHARS = 10_000 # Per-tool result cap
|
|
25
|
+
MAX_MESSAGE_TOOL_RESULTS_CHARS = 50_000 # Aggregate cap for all tool results in one message
|
|
26
|
+
CONTEXT_THRESHOLD_TOKENS = 80_000
|
|
27
|
+
MICRO_COMPACT_KEEP_RECENT = 2
|
|
19
28
|
|
|
20
29
|
POLL_INTERVAL = 5
|
|
21
30
|
IDLE_TIMEOUT = 60
|
|
@@ -23,15 +32,15 @@ module RubynCode
|
|
|
23
32
|
SESSION_BUDGET_USD = 5.00
|
|
24
33
|
DAILY_BUDGET_USD = 10.00
|
|
25
34
|
|
|
26
|
-
OAUTH_CLIENT_ID =
|
|
27
|
-
OAUTH_REDIRECT_URI =
|
|
28
|
-
OAUTH_AUTHORIZE_URL =
|
|
29
|
-
OAUTH_TOKEN_URL =
|
|
30
|
-
OAUTH_SCOPES =
|
|
35
|
+
OAUTH_CLIENT_ID = 'rubyn-code'
|
|
36
|
+
OAUTH_REDIRECT_URI = 'http://localhost:19275/callback'
|
|
37
|
+
OAUTH_AUTHORIZE_URL = 'https://claude.ai/oauth/authorize'
|
|
38
|
+
OAUTH_TOKEN_URL = 'https://claude.ai/oauth/token'
|
|
39
|
+
OAUTH_SCOPES = 'user:read model:read model:write'
|
|
31
40
|
|
|
32
41
|
DANGEROUS_PATTERNS = [
|
|
33
|
-
|
|
34
|
-
|
|
42
|
+
'rm -rf /', 'sudo rm', 'shutdown', 'reboot',
|
|
43
|
+
'> /dev/', 'mkfs', 'dd if=', ':(){:|:&};:'
|
|
35
44
|
].freeze
|
|
36
45
|
|
|
37
46
|
SCRUB_ENV_VARS = %w[
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require_relative
|
|
6
|
-
require_relative
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require_relative 'defaults'
|
|
6
|
+
require_relative 'settings'
|
|
7
7
|
|
|
8
8
|
module RubynCode
|
|
9
9
|
module Config
|
|
10
10
|
class ProjectConfig
|
|
11
11
|
class LoadError < StandardError; end
|
|
12
12
|
|
|
13
|
-
PROJECT_DIR_NAME =
|
|
14
|
-
CONFIG_FILENAME =
|
|
13
|
+
PROJECT_DIR_NAME = '.rubyn-code'
|
|
14
|
+
CONFIG_FILENAME = 'config.yml'
|
|
15
15
|
|
|
16
16
|
attr_reader :project_root, :config_path, :data
|
|
17
17
|
|
|
@@ -73,9 +73,7 @@ module RubynCode
|
|
|
73
73
|
|
|
74
74
|
loop do
|
|
75
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
|
|
76
|
+
return new(project_root: dir, global_settings: global_settings) if File.exist?(candidate)
|
|
79
77
|
|
|
80
78
|
parent = File.dirname(dir)
|
|
81
79
|
break if parent == dir # filesystem root reached
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Layer 4: Context Management
|
|
2
|
+
|
|
3
|
+
Manages the conversation context window to stay within Claude's token limits.
|
|
4
|
+
|
|
5
|
+
## Classes
|
|
6
|
+
|
|
7
|
+
- **`Manager`** — Orchestrates context strategy. Tracks token usage, decides when
|
|
8
|
+
compaction is needed, selects the right compaction strategy.
|
|
9
|
+
|
|
10
|
+
- **`Compactor`** — Base compaction logic. Sends the conversation to Claude with a
|
|
11
|
+
summarization prompt, replaces old messages with the summary.
|
|
12
|
+
|
|
13
|
+
- **`AutoCompact`** — Triggers automatically when token usage exceeds a threshold
|
|
14
|
+
(e.g. 80% of the context window). Runs transparently mid-conversation.
|
|
15
|
+
|
|
16
|
+
- **`MicroCompact`** — Lightweight compaction that trims tool results (large file
|
|
17
|
+
contents, long bash outputs) without summarizing the whole conversation.
|
|
18
|
+
|
|
19
|
+
- **`ManualCompact`** — Triggered by the user via `/compact`. Lets you specify a
|
|
20
|
+
focus area for the summary (e.g. "focus on the auth refactor").
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
5
|
|
|
6
6
|
module RubynCode
|
|
7
7
|
module Context
|
|
@@ -33,13 +33,13 @@ module RubynCode
|
|
|
33
33
|
transcript_text = serialize_tail(messages, MAX_TRANSCRIPT_CHARS)
|
|
34
34
|
summary = request_summary(transcript_text, llm_client)
|
|
35
35
|
|
|
36
|
-
[{ role:
|
|
36
|
+
[{ role: 'user', content: "[Context compacted]\n\n#{summary}" }]
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
# Persists the full conversation to a timestamped JSON file.
|
|
40
40
|
def self.save_transcript(messages, dir)
|
|
41
41
|
FileUtils.mkdir_p(dir)
|
|
42
|
-
timestamp = Time.now.strftime(
|
|
42
|
+
timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
|
|
43
43
|
path = File.join(dir, "transcript_#{timestamp}.json")
|
|
44
44
|
File.write(path, JSON.pretty_generate(messages))
|
|
45
45
|
path
|
|
@@ -57,19 +57,19 @@ module RubynCode
|
|
|
57
57
|
def self.request_summary(transcript_text, llm_client)
|
|
58
58
|
summary_messages = [
|
|
59
59
|
{
|
|
60
|
-
role:
|
|
60
|
+
role: 'user',
|
|
61
61
|
content: "#{SUMMARY_INSTRUCTION}\n\n---\n\n#{transcript_text}"
|
|
62
62
|
}
|
|
63
63
|
]
|
|
64
64
|
|
|
65
65
|
options = {}
|
|
66
|
-
options[:model] =
|
|
66
|
+
options[:model] = 'claude-sonnet-4-20250514' if llm_client.respond_to?(:chat)
|
|
67
67
|
|
|
68
68
|
response = llm_client.chat(messages: summary_messages, **options)
|
|
69
69
|
|
|
70
70
|
case response
|
|
71
71
|
when String then response
|
|
72
|
-
when Hash then response[:content] || response[
|
|
72
|
+
when Hash then response[:content] || response['content'] || response.to_s
|
|
73
73
|
else
|
|
74
74
|
response.respond_to?(:text) ? response.text : response.to_s
|
|
75
75
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'json'
|
|
4
4
|
|
|
5
5
|
module RubynCode
|
|
6
6
|
module Context
|
|
@@ -82,7 +82,7 @@ module RubynCode
|
|
|
82
82
|
def ensure_llm_client!
|
|
83
83
|
return if @llm_client
|
|
84
84
|
|
|
85
|
-
raise RubynCode::Error,
|
|
85
|
+
raise RubynCode::Error, 'LLM client is required for summarization-based compaction'
|
|
86
86
|
end
|
|
87
87
|
end
|
|
88
88
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Context
|
|
5
|
+
# Lightweight context reduction that removes old conversation turns without
|
|
6
|
+
# calling the LLM. Runs before auto-compact — if collapse alone brings the
|
|
7
|
+
# context under threshold, the expensive LLM summarization is skipped.
|
|
8
|
+
#
|
|
9
|
+
# Keeps the first message (initial user request), the most recent N exchanges,
|
|
10
|
+
# and replaces everything in between with a "[earlier conversation snipped]" marker.
|
|
11
|
+
module ContextCollapse
|
|
12
|
+
SNIP_MARKER = '[%d earlier messages snipped for context efficiency]'
|
|
13
|
+
CHARS_PER_TOKEN = 4
|
|
14
|
+
|
|
15
|
+
# Returns a collapsed copy of messages if doing so brings the estimated
|
|
16
|
+
# token count under threshold. Returns nil if collapse isn't sufficient
|
|
17
|
+
# (caller should fall through to full auto-compact).
|
|
18
|
+
#
|
|
19
|
+
# @param messages [Array<Hash>] conversation messages
|
|
20
|
+
# @param threshold [Integer] target token count
|
|
21
|
+
# @param keep_recent [Integer] number of recent messages to preserve
|
|
22
|
+
# @return [Array<Hash>, nil] collapsed messages or nil if not sufficient
|
|
23
|
+
def self.call(messages, threshold:, keep_recent: 6)
|
|
24
|
+
return nil if messages.size <= keep_recent + 2
|
|
25
|
+
|
|
26
|
+
# Keep first message + last N messages, snip the middle
|
|
27
|
+
first = messages.first
|
|
28
|
+
recent = messages.last(keep_recent)
|
|
29
|
+
snipped_count = messages.size - keep_recent - 1
|
|
30
|
+
|
|
31
|
+
collapsed = [
|
|
32
|
+
first,
|
|
33
|
+
{ role: 'user', content: format(SNIP_MARKER, snipped_count) },
|
|
34
|
+
*recent
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
# Only use collapse if it gets us under threshold
|
|
38
|
+
estimated = (JSON.generate(collapsed).length.to_f / CHARS_PER_TOKEN).ceil
|
|
39
|
+
estimated <= threshold ? collapsed : nil
|
|
40
|
+
rescue JSON::GeneratorError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'json'
|
|
4
4
|
|
|
5
5
|
module RubynCode
|
|
6
6
|
module Context
|
|
@@ -13,7 +13,7 @@ module RubynCode
|
|
|
13
13
|
attr_reader :total_input_tokens, :total_output_tokens
|
|
14
14
|
|
|
15
15
|
# @param threshold [Integer] estimated token count that triggers auto-compaction
|
|
16
|
-
def initialize(threshold:
|
|
16
|
+
def initialize(threshold: Config::Defaults::CONTEXT_THRESHOLD_TOKENS)
|
|
17
17
|
@threshold = threshold
|
|
18
18
|
@total_input_tokens = 0
|
|
19
19
|
@total_output_tokens = 0
|
|
@@ -53,13 +53,30 @@ module RubynCode
|
|
|
53
53
|
#
|
|
54
54
|
# @param conversation [#messages, #messages=] conversation wrapper
|
|
55
55
|
# @return [void]
|
|
56
|
+
# Fraction of the compaction threshold at which micro-compact kicks in.
|
|
57
|
+
# Running it too early busts the prompt cache prefix (mutated messages
|
|
58
|
+
# change the hash, invalidating server-side cached tokens).
|
|
59
|
+
MICRO_COMPACT_RATIO = 0.7
|
|
60
|
+
|
|
56
61
|
def check_compaction!(conversation)
|
|
57
62
|
messages = conversation.messages
|
|
58
63
|
|
|
59
|
-
|
|
64
|
+
# Step 1: Zero-cost micro-compact — but only when we're approaching
|
|
65
|
+
# the compaction threshold. Running it every turn mutates old messages,
|
|
66
|
+
# which invalidates the prompt cache prefix and wastes tokens.
|
|
67
|
+
est = estimated_tokens(messages)
|
|
68
|
+
MicroCompact.call(messages) if est > (@threshold * MICRO_COMPACT_RATIO)
|
|
60
69
|
|
|
61
70
|
return unless needs_compaction?(messages)
|
|
62
71
|
|
|
72
|
+
# Step 2: Try context collapse (snip old messages, no LLM call)
|
|
73
|
+
collapsed = ContextCollapse.call(messages, threshold: @threshold)
|
|
74
|
+
if collapsed
|
|
75
|
+
apply_compacted_messages(conversation, collapsed)
|
|
76
|
+
return
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Step 3: Full LLM-driven auto-compact (expensive, last resort)
|
|
63
80
|
compactor = Compactor.new(
|
|
64
81
|
llm_client: conversation.respond_to?(:llm_client) ? conversation.llm_client : nil,
|
|
65
82
|
threshold: @threshold
|