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,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
|
|
@@ -35,7 +35,7 @@ module RubynCode
|
|
|
35
35
|
instruction = build_instruction(focus)
|
|
36
36
|
summary = request_summary(transcript_text, instruction, llm_client)
|
|
37
37
|
|
|
38
|
-
[{ role:
|
|
38
|
+
[{ role: 'user', content: "[Context compacted — manual]\n\n#{summary}" }]
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def self.build_instruction(focus)
|
|
@@ -46,7 +46,7 @@ module RubynCode
|
|
|
46
46
|
|
|
47
47
|
def self.save_transcript(messages, dir)
|
|
48
48
|
FileUtils.mkdir_p(dir)
|
|
49
|
-
timestamp = Time.now.strftime(
|
|
49
|
+
timestamp = Time.now.strftime('%Y%m%d_%H%M%S')
|
|
50
50
|
path = File.join(dir, "transcript_manual_#{timestamp}.json")
|
|
51
51
|
File.write(path, JSON.pretty_generate(messages))
|
|
52
52
|
path
|
|
@@ -62,19 +62,19 @@ module RubynCode
|
|
|
62
62
|
def self.request_summary(transcript_text, instruction, llm_client)
|
|
63
63
|
summary_messages = [
|
|
64
64
|
{
|
|
65
|
-
role:
|
|
65
|
+
role: 'user',
|
|
66
66
|
content: "#{instruction}\n\n---\n\n#{transcript_text}"
|
|
67
67
|
}
|
|
68
68
|
]
|
|
69
69
|
|
|
70
70
|
options = {}
|
|
71
|
-
options[:model] =
|
|
71
|
+
options[:model] = 'claude-sonnet-4-20250514' if llm_client.respond_to?(:chat)
|
|
72
72
|
|
|
73
73
|
response = llm_client.chat(messages: summary_messages, **options)
|
|
74
74
|
|
|
75
75
|
case response
|
|
76
76
|
when String then response
|
|
77
|
-
when Hash then response[:content] || response[
|
|
77
|
+
when Hash then response[:content] || response['content'] || response.to_s
|
|
78
78
|
else
|
|
79
79
|
response.respond_to?(:text) ? response.text : response.to_s
|
|
80
80
|
end
|
|
@@ -6,7 +6,7 @@ module RubynCode
|
|
|
6
6
|
# (except the most recent N) with short placeholders to reduce token count
|
|
7
7
|
# without losing conversational continuity.
|
|
8
8
|
module MicroCompact
|
|
9
|
-
PLACEHOLDER_TEMPLATE =
|
|
9
|
+
PLACEHOLDER_TEMPLATE = '[Previous: used %<tool_name>s]'
|
|
10
10
|
MIN_CONTENT_LENGTH = 100
|
|
11
11
|
|
|
12
12
|
# Mutates +messages+ in place, replacing old tool_result content with
|
|
@@ -16,7 +16,7 @@ module RubynCode
|
|
|
16
16
|
# @param keep_recent [Integer] number of most-recent tool results to preserve
|
|
17
17
|
# @param preserve_tools [Array<String>] tool names whose results are never compacted
|
|
18
18
|
# @return [Integer] count of compacted tool results
|
|
19
|
-
def self.call(messages, keep_recent:
|
|
19
|
+
def self.call(messages, keep_recent: 2, preserve_tools: [])
|
|
20
20
|
tool_result_refs = collect_tool_results(messages)
|
|
21
21
|
return 0 if tool_result_refs.size <= keep_recent
|
|
22
22
|
|
|
@@ -32,7 +32,7 @@ module RubynCode
|
|
|
32
32
|
tool_name = resolve_tool_name(block, tool_name_index)
|
|
33
33
|
next if preserve_tools.include?(tool_name)
|
|
34
34
|
|
|
35
|
-
placeholder = format(PLACEHOLDER_TEMPLATE, tool_name: tool_name ||
|
|
35
|
+
placeholder = format(PLACEHOLDER_TEMPLATE, tool_name: tool_name || 'tool')
|
|
36
36
|
replace_content!(block, placeholder)
|
|
37
37
|
compacted += 1
|
|
38
38
|
end
|
|
@@ -48,7 +48,7 @@ module RubynCode
|
|
|
48
48
|
refs = []
|
|
49
49
|
|
|
50
50
|
messages.each do |msg|
|
|
51
|
-
next unless msg[:role] ==
|
|
51
|
+
next unless msg[:role] == 'user' && msg[:content].is_a?(Array)
|
|
52
52
|
|
|
53
53
|
msg[:content].each_with_index do |block, idx|
|
|
54
54
|
next unless tool_result_block?(block)
|
|
@@ -68,12 +68,12 @@ module RubynCode
|
|
|
68
68
|
index = {}
|
|
69
69
|
|
|
70
70
|
messages.each do |msg|
|
|
71
|
-
next unless msg[:role] ==
|
|
71
|
+
next unless msg[:role] == 'assistant' && msg[:content].is_a?(Array)
|
|
72
72
|
|
|
73
73
|
msg[:content].each do |block|
|
|
74
74
|
case block
|
|
75
75
|
when Hash
|
|
76
|
-
index[block[:id] || block[
|
|
76
|
+
index[block[:id] || block['id']] = block[:name] || block['name'] if block_type(block) == 'tool_use'
|
|
77
77
|
when LLM::ToolUseBlock
|
|
78
78
|
index[block.id] = block.name
|
|
79
79
|
end
|
|
@@ -86,7 +86,7 @@ module RubynCode
|
|
|
86
86
|
def self.tool_result_block?(block)
|
|
87
87
|
case block
|
|
88
88
|
when Hash
|
|
89
|
-
block_type(block) ==
|
|
89
|
+
block_type(block) == 'tool_result'
|
|
90
90
|
when LLM::ToolResultBlock
|
|
91
91
|
true
|
|
92
92
|
else
|
|
@@ -95,13 +95,13 @@ module RubynCode
|
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
def self.block_type(hash)
|
|
98
|
-
hash[:type] || hash[
|
|
98
|
+
hash[:type] || hash['type']
|
|
99
99
|
end
|
|
100
100
|
|
|
101
101
|
def self.extract_content(block)
|
|
102
102
|
case block
|
|
103
103
|
when Hash
|
|
104
|
-
val = block[:content] || block[
|
|
104
|
+
val = block[:content] || block['content']
|
|
105
105
|
val.is_a?(String) ? val : val.to_s
|
|
106
106
|
when LLM::ToolResultBlock
|
|
107
107
|
block.content.to_s
|
|
@@ -110,7 +110,7 @@ module RubynCode
|
|
|
110
110
|
|
|
111
111
|
def self.resolve_tool_name(block, index)
|
|
112
112
|
tool_use_id = case block
|
|
113
|
-
when Hash then block[:tool_use_id] || block[
|
|
113
|
+
when Hash then block[:tool_use_id] || block['tool_use_id']
|
|
114
114
|
when LLM::ToolResultBlock then block.tool_use_id
|
|
115
115
|
end
|
|
116
116
|
|
|
@@ -120,10 +120,10 @@ module RubynCode
|
|
|
120
120
|
def self.replace_content!(block, placeholder)
|
|
121
121
|
case block
|
|
122
122
|
when Hash
|
|
123
|
-
key = block.key?(:content) ? :content :
|
|
123
|
+
key = block.key?(:content) ? :content : 'content'
|
|
124
124
|
block[key] = placeholder
|
|
125
125
|
end
|
|
126
|
-
#
|
|
126
|
+
# NOTE: Data.define instances are frozen; for ToolResultBlock objects
|
|
127
127
|
# we rely on messages being stored as hashes in the conversation array.
|
|
128
128
|
end
|
|
129
129
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# DB Layer
|
|
2
|
+
|
|
3
|
+
SQLite connection management and schema migrations.
|
|
4
|
+
|
|
5
|
+
## Classes
|
|
6
|
+
|
|
7
|
+
- **`Connection`** — Singleton SQLite3 connection to `~/.rubyn-code/rubyn_code.db`.
|
|
8
|
+
Creates the directory and database on first access. Configures WAL mode and foreign keys.
|
|
9
|
+
|
|
10
|
+
- **`Migrator`** — Runs numbered migration files from `db/migrations/`. Tracks applied migrations
|
|
11
|
+
in `schema_migrations` table. Migrations are idempotent and run in order.
|
|
12
|
+
Supports two formats:
|
|
13
|
+
- `.sql` — executed statement-by-statement inside a transaction
|
|
14
|
+
- `.rb` — Ruby module with `module_function def up(db)` for conditional/complex migrations
|
|
15
|
+
(e.g. detecting column names via `pragma_table_info` before altering)
|
|
16
|
+
|
|
17
|
+
- **`Schema`** — Schema introspection utilities. Checks table existence, column info.
|
|
18
|
+
Used by other layers to verify database state.
|
|
19
|
+
|
|
20
|
+
## Writing a Ruby Migration
|
|
21
|
+
|
|
22
|
+
Use `.rb` when you need branching logic that pure SQL can't handle (e.g. schema detection):
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# db/migrations/011_fix_something.rb
|
|
26
|
+
module Migration011FixSomething
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def up(db)
|
|
30
|
+
columns = db.query("SELECT name FROM pragma_table_info('my_table')").to_a
|
|
31
|
+
column_names = columns.map { |c| c['name'] }
|
|
32
|
+
|
|
33
|
+
if column_names.include?('old_column')
|
|
34
|
+
db.execute("ALTER TABLE my_table RENAME COLUMN old_column TO new_column")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Module name is derived from filename: `011_fix_something.rb` → `Migration011FixSomething`.
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
3
|
+
require 'sqlite3'
|
|
4
|
+
require 'monitor'
|
|
5
|
+
require 'fileutils'
|
|
6
6
|
|
|
7
7
|
module RubynCode
|
|
8
8
|
module DB
|
|
@@ -52,8 +52,8 @@ module RubynCode
|
|
|
52
52
|
#
|
|
53
53
|
# @yield the block to execute within the transaction
|
|
54
54
|
# @return [Object] the return value of the block
|
|
55
|
-
def transaction(&
|
|
56
|
-
instance.transaction(&
|
|
55
|
+
def transaction(&)
|
|
56
|
+
instance.transaction(&)
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
# Tears down the singleton instance. Intended for test cleanup.
|
|
@@ -118,14 +118,14 @@ module RubynCode
|
|
|
118
118
|
begin
|
|
119
119
|
result = yield
|
|
120
120
|
if @transaction_depth == 1
|
|
121
|
-
@db.execute(
|
|
121
|
+
@db.execute('COMMIT')
|
|
122
122
|
else
|
|
123
123
|
@db.execute("RELEASE SAVEPOINT sp_#{@transaction_depth}")
|
|
124
124
|
end
|
|
125
125
|
result
|
|
126
126
|
rescue StandardError => e
|
|
127
127
|
if @transaction_depth == 1
|
|
128
|
-
@db.execute(
|
|
128
|
+
@db.execute('ROLLBACK')
|
|
129
129
|
else
|
|
130
130
|
@db.execute("ROLLBACK TO SAVEPOINT sp_#{@transaction_depth}")
|
|
131
131
|
@db.execute("RELEASE SAVEPOINT sp_#{@transaction_depth}")
|
|
@@ -157,15 +157,15 @@ module RubynCode
|
|
|
157
157
|
|
|
158
158
|
def configure_connection
|
|
159
159
|
@db.results_as_hash = true
|
|
160
|
-
@db.execute(
|
|
161
|
-
@db.execute(
|
|
162
|
-
@db.execute(
|
|
163
|
-
@db.execute(
|
|
164
|
-
@db.execute(
|
|
160
|
+
@db.execute('PRAGMA journal_mode = WAL')
|
|
161
|
+
@db.execute('PRAGMA foreign_keys = ON')
|
|
162
|
+
@db.execute('PRAGMA busy_timeout = 5000')
|
|
163
|
+
@db.execute('PRAGMA synchronous = NORMAL')
|
|
164
|
+
@db.execute('PRAGMA cache_size = -20000') # 20 MB
|
|
165
165
|
end
|
|
166
166
|
|
|
167
167
|
def begin_top_level_transaction
|
|
168
|
-
@db.execute(
|
|
168
|
+
@db.execute('BEGIN IMMEDIATE')
|
|
169
169
|
end
|
|
170
170
|
|
|
171
171
|
def begin_savepoint
|
|
@@ -2,11 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module RubynCode
|
|
4
4
|
module DB
|
|
5
|
-
# Reads
|
|
5
|
+
# Reads migration files from db/migrations/, tracks applied versions
|
|
6
6
|
# in a schema_migrations table, and applies new migrations in order.
|
|
7
|
+
#
|
|
8
|
+
# Supports two migration formats:
|
|
9
|
+
# - `.sql` files: executed statement-by-statement inside a transaction
|
|
10
|
+
# - `.rb` files: loaded and called via `ModuleName.up(connection)`
|
|
7
11
|
class Migrator
|
|
8
12
|
# @return [String] absolute path to the migrations directory
|
|
9
|
-
MIGRATIONS_DIR = File.expand_path(
|
|
13
|
+
MIGRATIONS_DIR = File.expand_path('../../../db/migrations', __dir__).freeze
|
|
10
14
|
|
|
11
15
|
# @param connection [Connection] the database connection to migrate
|
|
12
16
|
def initialize(connection)
|
|
@@ -34,7 +38,7 @@ module RubynCode
|
|
|
34
38
|
# @return [Array<Array(Integer, String)>] pairs of [version, file_path]
|
|
35
39
|
def pending_migrations
|
|
36
40
|
applied = applied_versions
|
|
37
|
-
available_migrations.
|
|
41
|
+
available_migrations.except(*applied)
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
# Returns the set of already-applied migration versions.
|
|
@@ -42,9 +46,9 @@ module RubynCode
|
|
|
42
46
|
# @return [Set<Integer>]
|
|
43
47
|
def applied_versions
|
|
44
48
|
rows = @connection.query(
|
|
45
|
-
|
|
49
|
+
'SELECT version FROM schema_migrations ORDER BY version'
|
|
46
50
|
).to_a
|
|
47
|
-
rows.
|
|
51
|
+
rows.to_set { |row| row['version'] }
|
|
48
52
|
end
|
|
49
53
|
|
|
50
54
|
# Returns the current schema version (highest applied migration).
|
|
@@ -52,20 +56,28 @@ module RubynCode
|
|
|
52
56
|
# @return [Integer, nil]
|
|
53
57
|
def current_version
|
|
54
58
|
row = @connection.query(
|
|
55
|
-
|
|
59
|
+
'SELECT MAX(version) AS max_version FROM schema_migrations'
|
|
56
60
|
).to_a.first
|
|
57
|
-
row && row[
|
|
61
|
+
row && row['max_version']
|
|
58
62
|
end
|
|
59
63
|
|
|
60
64
|
# Lists all available migration files sorted by version.
|
|
61
65
|
#
|
|
62
66
|
# @return [Array<Array(Integer, String)>] pairs of [version, file_path]
|
|
63
67
|
def available_migrations
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
all = Dir.glob(File.join(MIGRATIONS_DIR, '*'))
|
|
69
|
+
.select { |path| path.end_with?('.sql', '.rb') }
|
|
70
|
+
.map { |path| parse_migration_file(path) }
|
|
71
|
+
.compact
|
|
72
|
+
|
|
73
|
+
# Deduplicate: if both .rb and .sql exist for the same version, prefer .rb
|
|
74
|
+
by_version = {}
|
|
75
|
+
all.each do |version, path|
|
|
76
|
+
existing = by_version[version]
|
|
77
|
+
by_version[version] = [version, path] if existing.nil? || path.end_with?('.rb')
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
by_version.values.sort_by(&:first)
|
|
69
81
|
end
|
|
70
82
|
|
|
71
83
|
private
|
|
@@ -80,18 +92,42 @@ module RubynCode
|
|
|
80
92
|
end
|
|
81
93
|
|
|
82
94
|
def apply_migration(version, path)
|
|
83
|
-
sql = File.read(path)
|
|
84
95
|
@connection.transaction do
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
96
|
+
if path.end_with?('.rb')
|
|
97
|
+
apply_ruby_migration(path)
|
|
98
|
+
else
|
|
99
|
+
apply_sql_migration(path)
|
|
88
100
|
end
|
|
101
|
+
|
|
89
102
|
@connection.execute(
|
|
90
|
-
|
|
103
|
+
'INSERT INTO schema_migrations (version) VALUES (?)', [version]
|
|
91
104
|
)
|
|
92
105
|
end
|
|
93
106
|
end
|
|
94
107
|
|
|
108
|
+
def apply_sql_migration(path)
|
|
109
|
+
sql = File.read(path)
|
|
110
|
+
split_statements(sql).each do |statement|
|
|
111
|
+
@connection.execute(statement)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Loads a Ruby migration file and calls its `.up` method.
|
|
116
|
+
# The migration module must define `module_function def up(db)`.
|
|
117
|
+
def apply_ruby_migration(path)
|
|
118
|
+
require path
|
|
119
|
+
module_name = extract_module_name(path)
|
|
120
|
+
mod = Object.const_get(module_name)
|
|
121
|
+
mod.up(@connection)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Derives the module name from a migration filename.
|
|
125
|
+
# e.g. "011_fix_mailbox_messages_columns.rb" -> "Migration011FixMailboxMessagesColumns"
|
|
126
|
+
def extract_module_name(path)
|
|
127
|
+
basename = File.basename(path, '.rb')
|
|
128
|
+
"Migration#{basename.split('_').map(&:capitalize).join}"
|
|
129
|
+
end
|
|
130
|
+
|
|
95
131
|
# Splits a SQL file into individual statements, handling semicolons
|
|
96
132
|
# inside string literals and ignoring empty/comment-only fragments.
|
|
97
133
|
#
|
|
@@ -99,34 +135,37 @@ module RubynCode
|
|
|
99
135
|
# @return [Array<String>]
|
|
100
136
|
def split_statements(sql)
|
|
101
137
|
statements = []
|
|
102
|
-
current = +
|
|
138
|
+
current = +''
|
|
103
139
|
in_block = false
|
|
104
140
|
|
|
105
141
|
sql.each_line do |line|
|
|
106
142
|
stripped = line.strip
|
|
107
143
|
|
|
108
144
|
# Track BEGIN/END blocks (e.g., triggers)
|
|
109
|
-
|
|
145
|
+
if stripped.match?(/\bBEGIN\b/i) && !stripped.match?(/\ABEGIN\s+(IMMEDIATE|DEFERRED|EXCLUSIVE)/i)
|
|
146
|
+
in_block = true
|
|
147
|
+
end
|
|
110
148
|
current << line
|
|
111
149
|
|
|
112
150
|
if in_block
|
|
113
151
|
if stripped.match?(/\bEND\b\s*;?\s*$/i)
|
|
114
152
|
in_block = false
|
|
115
|
-
statements << current.strip.chomp(
|
|
116
|
-
current = +
|
|
153
|
+
statements << current.strip.chomp(';')
|
|
154
|
+
current = +''
|
|
117
155
|
end
|
|
118
|
-
elsif stripped.end_with?(
|
|
119
|
-
stmt = current.strip.chomp(
|
|
156
|
+
elsif stripped.end_with?(';')
|
|
157
|
+
stmt = current.strip.chomp(';').strip
|
|
120
158
|
statements << stmt unless stmt.empty? || (stmt.match?(/\A\s*--/) && !stmt.include?("\n"))
|
|
121
|
-
current = +
|
|
159
|
+
current = +''
|
|
122
160
|
end
|
|
123
161
|
end
|
|
124
162
|
|
|
125
163
|
# Handle any remaining content
|
|
126
|
-
remainder = current.strip.chomp(
|
|
164
|
+
remainder = current.strip.chomp(';').strip
|
|
127
165
|
statements << remainder unless remainder.empty?
|
|
128
166
|
|
|
129
|
-
statements
|
|
167
|
+
# Filter out comment-only statements
|
|
168
|
+
statements.reject { |s| s.lines.all? { |l| l.strip.empty? || l.strip.start_with?('--') } }
|
|
130
169
|
end
|
|
131
170
|
|
|
132
171
|
# Extracts the version number and name from a migration filename.
|
|
@@ -134,7 +173,8 @@ module RubynCode
|
|
|
134
173
|
# @param path [String]
|
|
135
174
|
# @return [Array(Integer, String), nil]
|
|
136
175
|
def parse_migration_file(path)
|
|
137
|
-
|
|
176
|
+
ext = File.extname(path)
|
|
177
|
+
basename = File.basename(path, ext)
|
|
138
178
|
match = basename.match(/\A(\d+)_/)
|
|
139
179
|
return nil unless match
|
|
140
180
|
|
data/lib/rubyn_code/db/schema.rb
CHANGED
|
@@ -15,9 +15,9 @@ module RubynCode
|
|
|
15
15
|
# @return [Integer, nil] the version number, or nil if no migrations applied
|
|
16
16
|
def current_version
|
|
17
17
|
row = @connection.query(
|
|
18
|
-
|
|
18
|
+
'SELECT MAX(version) AS max_version FROM schema_migrations'
|
|
19
19
|
).to_a.first
|
|
20
|
-
row && row[
|
|
20
|
+
row && row['max_version']
|
|
21
21
|
rescue StandardError
|
|
22
22
|
nil
|
|
23
23
|
end
|
|
@@ -27,8 +27,8 @@ module RubynCode
|
|
|
27
27
|
# @return [Array<Integer>]
|
|
28
28
|
def applied_versions
|
|
29
29
|
@connection.query(
|
|
30
|
-
|
|
31
|
-
).to_a.map { |row| row[
|
|
30
|
+
'SELECT version FROM schema_migrations ORDER BY version'
|
|
31
|
+
).to_a.map { |row| row['version'] }
|
|
32
32
|
rescue StandardError
|
|
33
33
|
[]
|
|
34
34
|
end
|
|
@@ -39,7 +39,7 @@ module RubynCode
|
|
|
39
39
|
# @return [Boolean]
|
|
40
40
|
def version_applied?(version)
|
|
41
41
|
rows = @connection.query(
|
|
42
|
-
|
|
42
|
+
'SELECT 1 FROM schema_migrations WHERE version = ?', [version]
|
|
43
43
|
).to_a
|
|
44
44
|
!rows.empty?
|
|
45
45
|
rescue StandardError
|
|
@@ -53,7 +53,7 @@ module RubynCode
|
|
|
53
53
|
@connection.query(
|
|
54
54
|
"SELECT name FROM sqlite_master WHERE type = 'table' " \
|
|
55
55
|
"AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
|
56
|
-
).to_a.map { |row| row[
|
|
56
|
+
).to_a.map { |row| row['name'] }
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
# Returns column information for the given table.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pastel'
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module Debug
|
|
7
|
+
PASTEL = Pastel.new
|
|
8
|
+
|
|
9
|
+
@enabled = false
|
|
10
|
+
@output = $stderr
|
|
11
|
+
|
|
12
|
+
class << self
|
|
13
|
+
attr_reader :enabled
|
|
14
|
+
|
|
15
|
+
def enable!
|
|
16
|
+
@enabled = true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def disable!
|
|
20
|
+
@enabled = false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def enabled?
|
|
24
|
+
@enabled || ENV.fetch('RUBYN_DEBUG', nil)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_writer :output
|
|
28
|
+
|
|
29
|
+
# ── Core logging ──────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
def log(tag, message, color: :dim)
|
|
32
|
+
return unless enabled?
|
|
33
|
+
|
|
34
|
+
timestamp = Time.now.strftime('%H:%M:%S.%L')
|
|
35
|
+
prefix = "#{PASTEL.dim("[#{timestamp}]")} #{PASTEL.send(color, "[#{tag}]")}"
|
|
36
|
+
@output.puts "#{prefix} #{message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# ── Convenience methods ───────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
def llm(message)
|
|
42
|
+
log('llm', message, color: :magenta)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def tool(message)
|
|
46
|
+
log('tool', message, color: :cyan)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def agent(message)
|
|
50
|
+
log('agent', message, color: :yellow)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def loop_tick(message)
|
|
54
|
+
log('loop', message, color: :green)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def recovery(message)
|
|
58
|
+
log('recovery', message, color: :red)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def token(message)
|
|
62
|
+
log('token', message, color: :blue)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def warn(message)
|
|
66
|
+
log('warn', message, color: :yellow)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def error(message)
|
|
70
|
+
log('error', message, color: :red)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Layer 14: Hooks
|
|
2
|
+
|
|
3
|
+
Event hooks for extending agent behavior.
|
|
4
|
+
|
|
5
|
+
## Classes
|
|
6
|
+
|
|
7
|
+
- **`Registry`** — Stores hook definitions keyed by event name. Events include
|
|
8
|
+
`before_tool`, `after_tool`, `before_llm`, `after_llm`, `on_error`, etc.
|
|
9
|
+
|
|
10
|
+
- **`Runner`** — Executes registered hooks when events fire. Hooks run synchronously
|
|
11
|
+
in registration order. A hook can modify or abort the event.
|
|
12
|
+
|
|
13
|
+
- **`BuiltIn`** — Default hooks shipped with the gem: logging, cost tracking,
|
|
14
|
+
context auto-compaction triggers.
|
|
15
|
+
|
|
16
|
+
- **`UserHooks`** — Loads user-defined hooks from `~/.rubyn-code/hooks/` or
|
|
17
|
+
project-level `.rubyn-code/hooks/`. Ruby files that register via the `Registry`.
|
|
@@ -22,14 +22,14 @@ module RubynCode
|
|
|
22
22
|
def call(response:, **_kwargs)
|
|
23
23
|
return unless @budget_enforcer
|
|
24
24
|
|
|
25
|
-
usage = response[:usage] || response[
|
|
25
|
+
usage = response[:usage] || response['usage']
|
|
26
26
|
return unless usage
|
|
27
27
|
|
|
28
|
-
model = response[:model] || response[
|
|
29
|
-
input_tokens = usage[:input_tokens] || usage[
|
|
30
|
-
output_tokens = usage[:output_tokens] || usage[
|
|
31
|
-
cache_read = usage[:cache_read_input_tokens] || usage[
|
|
32
|
-
cache_write = usage[:cache_creation_input_tokens] || usage[
|
|
28
|
+
model = response[:model] || response['model'] || 'unknown'
|
|
29
|
+
input_tokens = usage[:input_tokens] || usage['input_tokens'] || 0
|
|
30
|
+
output_tokens = usage[:output_tokens] || usage['output_tokens'] || 0
|
|
31
|
+
cache_read = usage[:cache_read_input_tokens] || usage['cache_read_input_tokens'] || 0
|
|
32
|
+
cache_write = usage[:cache_creation_input_tokens] || usage['cache_creation_input_tokens'] || 0
|
|
33
33
|
|
|
34
34
|
@budget_enforcer.record!(
|
|
35
35
|
model: model,
|
|
@@ -114,9 +114,9 @@ module RubynCode
|
|
|
114
114
|
registry.on(:post_tool_use, logging_hook, priority: 50)
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
117
|
+
return unless context_manager
|
|
118
|
+
|
|
119
|
+
registry.on(:post_llm_call, AutoCompactHook.new(context_manager: context_manager), priority: 90)
|
|
120
120
|
end
|
|
121
121
|
end
|
|
122
122
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require 'monitor'
|
|
4
4
|
|
|
5
5
|
module RubynCode
|
|
6
6
|
module Hooks
|
|
@@ -24,7 +24,7 @@ module RubynCode
|
|
|
24
24
|
include MonitorMixin
|
|
25
25
|
|
|
26
26
|
def initialize
|
|
27
|
-
super
|
|
27
|
+
super # MonitorMixin
|
|
28
28
|
@hooks = {}
|
|
29
29
|
VALID_EVENTS.each { |event| @hooks[event] = [] }
|
|
30
30
|
end
|
|
@@ -41,8 +41,8 @@ module RubynCode
|
|
|
41
41
|
validate_event!(event)
|
|
42
42
|
|
|
43
43
|
handler = callable || block
|
|
44
|
-
raise ArgumentError,
|
|
45
|
-
raise ArgumentError,
|
|
44
|
+
raise ArgumentError, 'A callable or block is required' unless handler
|
|
45
|
+
raise ArgumentError, 'Hook must respond to #call' unless handler.respond_to?(:call)
|
|
46
46
|
|
|
47
47
|
synchronize do
|
|
48
48
|
@hooks[event] << Hook.new(callable: handler, priority: priority)
|
|
@@ -92,7 +92,7 @@ module RubynCode
|
|
|
92
92
|
return if VALID_EVENTS.include?(event)
|
|
93
93
|
|
|
94
94
|
raise ArgumentError,
|
|
95
|
-
"Unknown event #{event.inspect}. Valid events: #{VALID_EVENTS.join(
|
|
95
|
+
"Unknown event #{event.inspect}. Valid events: #{VALID_EVENTS.join(', ')}"
|
|
96
96
|
end
|
|
97
97
|
end
|
|
98
98
|
end
|
|
@@ -45,7 +45,7 @@ module RubynCode
|
|
|
45
45
|
result = safe_call(hook, :pre_tool_use, context)
|
|
46
46
|
next unless result.is_a?(Hash) && result[:deny]
|
|
47
47
|
|
|
48
|
-
return { deny: true, reason: result[:reason] ||
|
|
48
|
+
return { deny: true, reason: result[:reason] || 'Denied by hook' }
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
nil
|