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.
Files changed (159) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +269 -467
  3. data/db/migrations/009_create_teams.sql +6 -6
  4. data/db/migrations/011_fix_mailbox_messages_columns.rb +35 -0
  5. data/db/migrations/012_expand_mailbox_message_types.rb +37 -0
  6. data/exe/rubyn-code +1 -1
  7. data/lib/rubyn_code/agent/RUBYN.md +17 -0
  8. data/lib/rubyn_code/agent/conversation.rb +68 -19
  9. data/lib/rubyn_code/agent/loop.rb +312 -54
  10. data/lib/rubyn_code/agent/loop_detector.rb +6 -6
  11. data/lib/rubyn_code/auth/RUBYN.md +19 -0
  12. data/lib/rubyn_code/auth/oauth.rb +40 -35
  13. data/lib/rubyn_code/auth/server.rb +16 -12
  14. data/lib/rubyn_code/auth/token_store.rb +22 -22
  15. data/lib/rubyn_code/autonomous/RUBYN.md +14 -0
  16. data/lib/rubyn_code/autonomous/daemon.rb +115 -79
  17. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -8
  18. data/lib/rubyn_code/autonomous/task_claimer.rb +11 -11
  19. data/lib/rubyn_code/background/RUBYN.md +13 -0
  20. data/lib/rubyn_code/background/notifier.rb +0 -2
  21. data/lib/rubyn_code/background/worker.rb +60 -15
  22. data/lib/rubyn_code/cli/RUBYN.md +30 -0
  23. data/lib/rubyn_code/cli/app.rb +85 -9
  24. data/lib/rubyn_code/cli/commands/RUBYN.md +133 -0
  25. data/lib/rubyn_code/cli/commands/base.rb +53 -0
  26. data/lib/rubyn_code/cli/commands/budget.rb +24 -0
  27. data/lib/rubyn_code/cli/commands/clear.rb +16 -0
  28. data/lib/rubyn_code/cli/commands/compact.rb +21 -0
  29. data/lib/rubyn_code/cli/commands/context.rb +44 -0
  30. data/lib/rubyn_code/cli/commands/context_info.rb +56 -0
  31. data/lib/rubyn_code/cli/commands/cost.rb +23 -0
  32. data/lib/rubyn_code/cli/commands/diff.rb +30 -0
  33. data/lib/rubyn_code/cli/commands/doctor.rb +112 -0
  34. data/lib/rubyn_code/cli/commands/help.rb +41 -0
  35. data/lib/rubyn_code/cli/commands/model.rb +37 -0
  36. data/lib/rubyn_code/cli/commands/plan.rb +22 -0
  37. data/lib/rubyn_code/cli/commands/quit.rb +17 -0
  38. data/lib/rubyn_code/cli/commands/registry.rb +64 -0
  39. data/lib/rubyn_code/cli/commands/resume.rb +51 -0
  40. data/lib/rubyn_code/cli/commands/review.rb +26 -0
  41. data/lib/rubyn_code/cli/commands/skill.rb +32 -0
  42. data/lib/rubyn_code/cli/commands/spawn.rb +24 -0
  43. data/lib/rubyn_code/cli/commands/tasks.rb +32 -0
  44. data/lib/rubyn_code/cli/commands/tokens.rb +76 -0
  45. data/lib/rubyn_code/cli/commands/undo.rb +17 -0
  46. data/lib/rubyn_code/cli/commands/version.rb +16 -0
  47. data/lib/rubyn_code/cli/daemon_runner.rb +129 -0
  48. data/lib/rubyn_code/cli/input_handler.rb +20 -23
  49. data/lib/rubyn_code/cli/renderer.rb +25 -27
  50. data/lib/rubyn_code/cli/repl.rb +161 -194
  51. data/lib/rubyn_code/cli/setup.rb +117 -0
  52. data/lib/rubyn_code/cli/spinner.rb +40 -40
  53. data/lib/rubyn_code/cli/stream_formatter.rb +29 -28
  54. data/lib/rubyn_code/cli/version_check.rb +94 -0
  55. data/lib/rubyn_code/config/RUBYN.md +14 -0
  56. data/lib/rubyn_code/config/defaults.rb +28 -19
  57. data/lib/rubyn_code/config/project_config.rb +7 -9
  58. data/lib/rubyn_code/config/settings.rb +3 -3
  59. data/lib/rubyn_code/context/RUBYN.md +20 -0
  60. data/lib/rubyn_code/context/auto_compact.rb +7 -7
  61. data/lib/rubyn_code/context/compactor.rb +2 -2
  62. data/lib/rubyn_code/context/context_collapse.rb +45 -0
  63. data/lib/rubyn_code/context/manager.rb +20 -3
  64. data/lib/rubyn_code/context/manual_compact.rb +7 -7
  65. data/lib/rubyn_code/context/micro_compact.rb +12 -12
  66. data/lib/rubyn_code/db/RUBYN.md +40 -0
  67. data/lib/rubyn_code/db/connection.rb +13 -13
  68. data/lib/rubyn_code/db/migrator.rb +67 -27
  69. data/lib/rubyn_code/db/schema.rb +6 -6
  70. data/lib/rubyn_code/debug.rb +74 -0
  71. data/lib/rubyn_code/hooks/RUBYN.md +17 -0
  72. data/lib/rubyn_code/hooks/built_in.rb +9 -9
  73. data/lib/rubyn_code/hooks/registry.rb +5 -5
  74. data/lib/rubyn_code/hooks/runner.rb +1 -1
  75. data/lib/rubyn_code/hooks/user_hooks.rb +16 -16
  76. data/lib/rubyn_code/learning/RUBYN.md +16 -0
  77. data/lib/rubyn_code/learning/extractor.rb +22 -22
  78. data/lib/rubyn_code/learning/injector.rb +17 -18
  79. data/lib/rubyn_code/learning/instinct.rb +18 -14
  80. data/lib/rubyn_code/llm/RUBYN.md +15 -0
  81. data/lib/rubyn_code/llm/client.rb +121 -55
  82. data/lib/rubyn_code/llm/message_builder.rb +19 -15
  83. data/lib/rubyn_code/llm/streaming.rb +80 -50
  84. data/lib/rubyn_code/mcp/RUBYN.md +21 -0
  85. data/lib/rubyn_code/mcp/client.rb +25 -24
  86. data/lib/rubyn_code/mcp/config.rb +7 -7
  87. data/lib/rubyn_code/mcp/sse_transport.rb +27 -26
  88. data/lib/rubyn_code/mcp/stdio_transport.rb +22 -19
  89. data/lib/rubyn_code/mcp/tool_bridge.rb +32 -32
  90. data/lib/rubyn_code/memory/RUBYN.md +17 -0
  91. data/lib/rubyn_code/memory/models.rb +3 -3
  92. data/lib/rubyn_code/memory/search.rb +17 -17
  93. data/lib/rubyn_code/memory/session_persistence.rb +49 -34
  94. data/lib/rubyn_code/memory/store.rb +17 -17
  95. data/lib/rubyn_code/observability/RUBYN.md +19 -0
  96. data/lib/rubyn_code/observability/budget_enforcer.rb +16 -15
  97. data/lib/rubyn_code/observability/cost_calculator.rb +3 -3
  98. data/lib/rubyn_code/observability/token_counter.rb +1 -1
  99. data/lib/rubyn_code/observability/usage_reporter.rb +35 -35
  100. data/lib/rubyn_code/output/RUBYN.md +11 -0
  101. data/lib/rubyn_code/output/diff_renderer.rb +6 -6
  102. data/lib/rubyn_code/output/formatter.rb +4 -4
  103. data/lib/rubyn_code/permissions/RUBYN.md +17 -0
  104. data/lib/rubyn_code/permissions/prompter.rb +8 -8
  105. data/lib/rubyn_code/protocols/RUBYN.md +14 -0
  106. data/lib/rubyn_code/protocols/interrupt_handler.rb +1 -1
  107. data/lib/rubyn_code/protocols/plan_approval.rb +9 -9
  108. data/lib/rubyn_code/protocols/shutdown_handshake.rb +9 -11
  109. data/lib/rubyn_code/skills/RUBYN.md +19 -0
  110. data/lib/rubyn_code/skills/catalog.rb +7 -7
  111. data/lib/rubyn_code/skills/document.rb +15 -15
  112. data/lib/rubyn_code/skills/loader.rb +6 -8
  113. data/lib/rubyn_code/sub_agents/RUBYN.md +12 -0
  114. data/lib/rubyn_code/sub_agents/runner.rb +15 -15
  115. data/lib/rubyn_code/sub_agents/summarizer.rb +1 -1
  116. data/lib/rubyn_code/tasks/RUBYN.md +13 -0
  117. data/lib/rubyn_code/tasks/dag.rb +12 -16
  118. data/lib/rubyn_code/tasks/manager.rb +24 -24
  119. data/lib/rubyn_code/tasks/models.rb +4 -4
  120. data/lib/rubyn_code/teams/RUBYN.md +14 -0
  121. data/lib/rubyn_code/teams/mailbox.rb +38 -18
  122. data/lib/rubyn_code/teams/manager.rb +19 -19
  123. data/lib/rubyn_code/teams/teammate.rb +3 -4
  124. data/lib/rubyn_code/tools/RUBYN.md +38 -0
  125. data/lib/rubyn_code/tools/background_run.rb +9 -11
  126. data/lib/rubyn_code/tools/base.rb +54 -3
  127. data/lib/rubyn_code/tools/bash.rb +16 -34
  128. data/lib/rubyn_code/tools/bundle_add.rb +10 -12
  129. data/lib/rubyn_code/tools/bundle_install.rb +9 -11
  130. data/lib/rubyn_code/tools/compact.rb +10 -9
  131. data/lib/rubyn_code/tools/db_migrate.rb +17 -15
  132. data/lib/rubyn_code/tools/edit_file.rb +12 -12
  133. data/lib/rubyn_code/tools/executor.rb +9 -4
  134. data/lib/rubyn_code/tools/git_commit.rb +29 -34
  135. data/lib/rubyn_code/tools/git_diff.rb +17 -18
  136. data/lib/rubyn_code/tools/git_log.rb +17 -19
  137. data/lib/rubyn_code/tools/git_status.rb +18 -20
  138. data/lib/rubyn_code/tools/glob.rb +7 -9
  139. data/lib/rubyn_code/tools/grep.rb +11 -9
  140. data/lib/rubyn_code/tools/load_skill.rb +7 -7
  141. data/lib/rubyn_code/tools/memory_search.rb +13 -12
  142. data/lib/rubyn_code/tools/memory_write.rb +14 -12
  143. data/lib/rubyn_code/tools/rails_generate.rb +16 -16
  144. data/lib/rubyn_code/tools/read_file.rb +8 -7
  145. data/lib/rubyn_code/tools/read_inbox.rb +5 -5
  146. data/lib/rubyn_code/tools/registry.rb +2 -2
  147. data/lib/rubyn_code/tools/review_pr.rb +55 -55
  148. data/lib/rubyn_code/tools/run_specs.rb +20 -19
  149. data/lib/rubyn_code/tools/schema.rb +9 -11
  150. data/lib/rubyn_code/tools/send_message.rb +10 -10
  151. data/lib/rubyn_code/tools/spawn_agent.rb +51 -23
  152. data/lib/rubyn_code/tools/spawn_teammate.rb +21 -21
  153. data/lib/rubyn_code/tools/task.rb +28 -28
  154. data/lib/rubyn_code/tools/web_fetch.rb +46 -31
  155. data/lib/rubyn_code/tools/web_search.rb +64 -66
  156. data/lib/rubyn_code/tools/write_file.rb +7 -6
  157. data/lib/rubyn_code/version.rb +1 -1
  158. data/lib/rubyn_code.rb +136 -105
  159. metadata +94 -21
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "fileutils"
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: "user", content: "[Context compacted — manual]\n\n#{summary}" }]
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("%Y%m%d_%H%M%S")
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: "user",
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] = "claude-sonnet-4-20250514" if llm_client.respond_to?(:chat)
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["content"] || response.to_s
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 = "[Previous: used %<tool_name>s]"
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: 3, preserve_tools: ["read_file"])
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 || "tool")
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] == "user" && msg[:content].is_a?(Array)
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] == "assistant" && msg[:content].is_a?(Array)
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["id"]] = block[:name] || block["name"] if block_type(block) == "tool_use"
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) == "tool_result"
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["type"]
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["content"]
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["tool_use_id"]
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 : "content"
123
+ key = block.key?(:content) ? :content : 'content'
124
124
  block[key] = placeholder
125
125
  end
126
- # Note: Data.define instances are frozen; for ToolResultBlock objects
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 "sqlite3"
4
- require "monitor"
5
- require "fileutils"
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(&block)
56
- instance.transaction(&block)
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("COMMIT")
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("ROLLBACK")
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("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
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("BEGIN IMMEDIATE")
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 SQL migration files from db/migrations/, tracks applied versions
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("../../../db/migrations", __dir__).freeze
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.reject { |version, _| applied.include?(version) }
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
- "SELECT version FROM schema_migrations ORDER BY version"
49
+ 'SELECT version FROM schema_migrations ORDER BY version'
46
50
  ).to_a
47
- rows.map { |row| row["version"] }.to_set
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
- "SELECT MAX(version) AS max_version FROM schema_migrations"
59
+ 'SELECT MAX(version) AS max_version FROM schema_migrations'
56
60
  ).to_a.first
57
- row && row["max_version"]
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
- pattern = File.join(MIGRATIONS_DIR, "*.sql")
65
- Dir.glob(pattern)
66
- .map { |path| parse_migration_file(path) }
67
- .compact
68
- .sort_by(&:first)
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
- # Execute each statement separately (SQLite doesn't support multi-statement execute)
86
- split_statements(sql).each do |statement|
87
- @connection.execute(statement)
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
- "INSERT INTO schema_migrations (version) VALUES (?)", [version]
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
- in_block = true if stripped.match?(/\bBEGIN\b/i) && !stripped.match?(/\ABEGIN\s+(IMMEDIATE|DEFERRED|EXCLUSIVE)/i)
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(";").strip
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(";").strip
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
- basename = File.basename(path, ".sql")
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
 
@@ -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
- "SELECT MAX(version) AS max_version FROM schema_migrations"
18
+ 'SELECT MAX(version) AS max_version FROM schema_migrations'
19
19
  ).to_a.first
20
- row && row["max_version"]
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
- "SELECT version FROM schema_migrations ORDER BY version"
31
- ).to_a.map { |row| row["version"] }
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
- "SELECT 1 FROM schema_migrations WHERE version = ?", [version]
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["name"] }
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["usage"]
25
+ usage = response[:usage] || response['usage']
26
26
  return unless usage
27
27
 
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
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
- if context_manager
118
- registry.on(:post_llm_call, AutoCompactHook.new(context_manager: context_manager), priority: 90)
119
- end
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 "monitor"
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() # MonitorMixin
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, "A callable or block is required" unless handler
45
- raise ArgumentError, "Hook must respond to #call" unless handler.respond_to?(:call)
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] || "Denied by hook" }
48
+ return { deny: true, reason: result[:reason] || 'Denied by hook' }
49
49
  end
50
50
 
51
51
  nil