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 "securerandom"
4
- require "json"
3
+ require 'securerandom'
4
+ require 'json'
5
5
 
6
6
  module RubynCode
7
7
  module Memory
@@ -24,11 +24,11 @@ module RubynCode
24
24
  # @param metadata [Hash] arbitrary metadata
25
25
  # @return [void]
26
26
  def save_session(session_id:, project_path:, messages:, title: nil, model: nil, metadata: {})
27
- now = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
27
+ now = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S')
28
28
  messages_json = JSON.generate(messages)
29
29
  meta_json = JSON.generate(metadata)
30
30
 
31
- @db.execute(<<~SQL, [session_id, project_path, title, model, messages_json, "active", meta_json, now, now, messages_json, title, model, meta_json, now])
31
+ @db.execute(<<~SQL, [session_id, project_path, title, model, messages_json, 'active', meta_json, now, now, messages_json, title, model, meta_json, now])
32
32
  INSERT INTO sessions (id, project_path, title, model, messages, status, metadata, created_at, updated_at)
33
33
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
34
34
  ON CONFLICT(id) DO UPDATE SET
@@ -46,21 +46,21 @@ module RubynCode
46
46
  # @return [Hash, nil] { messages:, metadata:, title:, model:, status:, project_path: } or nil
47
47
  def load_session(session_id)
48
48
  rows = @db.query(
49
- "SELECT * FROM sessions WHERE id = ?",
49
+ 'SELECT * FROM sessions WHERE id = ?',
50
50
  [session_id]
51
51
  ).to_a
52
52
  return nil if rows.empty?
53
53
 
54
54
  row = rows.first
55
55
  {
56
- messages: parse_json_array(row["messages"]),
57
- metadata: parse_json_hash(row["metadata"]),
58
- title: row["title"],
59
- model: row["model"],
60
- status: row["status"],
61
- project_path: row["project_path"],
62
- created_at: row["created_at"],
63
- updated_at: row["updated_at"]
56
+ messages: parse_json_array(row['messages']),
57
+ metadata: parse_json_hash(row['metadata']),
58
+ title: row['title'],
59
+ model: row['model'],
60
+ status: row['status'],
61
+ project_path: row['project_path'],
62
+ created_at: row['created_at'],
63
+ updated_at: row['updated_at']
64
64
  }
65
65
  end
66
66
 
@@ -75,16 +75,16 @@ module RubynCode
75
75
  params = []
76
76
 
77
77
  if project_path
78
- conditions << "project_path = ?"
78
+ conditions << 'project_path = ?'
79
79
  params << project_path
80
80
  end
81
81
 
82
82
  if status
83
- conditions << "status = ?"
83
+ conditions << 'status = ?'
84
84
  params << status
85
85
  end
86
86
 
87
- where_clause = conditions.empty? ? "" : "WHERE #{conditions.join(' AND ')}"
87
+ where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
88
88
  params << limit
89
89
 
90
90
  rows = @db.query(<<~SQL, params).to_a
@@ -97,14 +97,14 @@ module RubynCode
97
97
 
98
98
  rows.map do |row|
99
99
  {
100
- id: row["id"],
101
- project_path: row["project_path"],
102
- title: row["title"],
103
- model: row["model"],
104
- status: row["status"],
105
- metadata: parse_json_hash(row["metadata"]),
106
- created_at: row["created_at"],
107
- updated_at: row["updated_at"]
100
+ id: row['id'],
101
+ project_path: row['project_path'],
102
+ title: row['title'],
103
+ model: row['model'],
104
+ status: row['status'],
105
+ metadata: parse_json_hash(row['metadata']),
106
+ created_at: row['created_at'],
107
+ updated_at: row['updated_at']
108
108
  }
109
109
  end
110
110
  end
@@ -123,27 +123,27 @@ module RubynCode
123
123
  attrs.each do |key, value|
124
124
  case key
125
125
  when :title
126
- sets << "title = ?"
126
+ sets << 'title = ?'
127
127
  params << value
128
128
  when :status
129
- sets << "status = ?"
129
+ sets << 'status = ?'
130
130
  params << value
131
131
  when :model
132
- sets << "model = ?"
132
+ sets << 'model = ?'
133
133
  params << value
134
134
  when :metadata
135
- sets << "metadata = ?"
135
+ sets << 'metadata = ?'
136
136
  params << JSON.generate(value)
137
137
  when :messages
138
- sets << "messages = ?"
138
+ sets << 'messages = ?'
139
139
  params << JSON.generate(value)
140
140
  end
141
141
  end
142
142
 
143
143
  return if sets.empty?
144
144
 
145
- sets << "updated_at = ?"
146
- params << Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
145
+ sets << 'updated_at = ?'
146
+ params << Time.now.utc.strftime('%Y-%m-%d %H:%M:%S')
147
147
  params << session_id
148
148
 
149
149
  @db.execute("UPDATE sessions SET #{sets.join(', ')} WHERE id = ?", params)
@@ -154,16 +154,31 @@ module RubynCode
154
154
  # @param session_id [String]
155
155
  # @return [void]
156
156
  def delete_session(session_id)
157
- @db.execute("DELETE FROM sessions WHERE id = ?", [session_id])
157
+ @db.execute('DELETE FROM sessions WHERE id = ?', [session_id])
158
158
  end
159
159
 
160
160
  private
161
161
 
162
162
  def ensure_table
163
- # Add messages column if it doesn't exist (migration schema didn't include it)
163
+ @db.execute(<<~SQL)
164
+ CREATE TABLE IF NOT EXISTS sessions (
165
+ id TEXT PRIMARY KEY,
166
+ project_path TEXT NOT NULL,
167
+ title TEXT,
168
+ model TEXT,
169
+ messages TEXT NOT NULL DEFAULT '[]',
170
+ status TEXT NOT NULL DEFAULT 'active',
171
+ metadata TEXT DEFAULT '{}',
172
+ created_at TEXT NOT NULL,
173
+ updated_at TEXT NOT NULL
174
+ )
175
+ SQL
176
+
177
+ # Add messages column for databases created by the original migration
178
+ # (001_create_sessions.sql) which omitted it
164
179
  @db.execute("ALTER TABLE sessions ADD COLUMN messages TEXT NOT NULL DEFAULT '[]'")
165
180
  rescue StandardError
166
- # Column already exists or table doesn't exist yet either way, safe to continue
181
+ # Column already exists — safe to continue
167
182
  end
168
183
 
169
184
  # @param raw [String, Array, nil]
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
- require "json"
5
- require_relative "models"
3
+ require 'securerandom'
4
+ require 'json'
5
+ require_relative 'models'
6
6
 
7
7
  module RubynCode
8
8
  module Memory
@@ -26,12 +26,12 @@ module RubynCode
26
26
  # @param metadata [Hash] arbitrary metadata
27
27
  # @param expires_at [String, nil] ISO 8601 expiration timestamp
28
28
  # @return [MemoryRecord] the created record
29
- def write(content:, tier: "medium", category: nil, metadata: {}, expires_at: nil)
29
+ def write(content:, tier: 'medium', category: nil, metadata: {}, expires_at: nil)
30
30
  validate_tier!(tier)
31
31
  validate_category!(category) if category
32
32
 
33
33
  id = SecureRandom.uuid
34
- now = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
34
+ now = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S')
35
35
  meta_json = JSON.generate(metadata)
36
36
 
37
37
  @db.execute(<<~SQL, [id, @project_path, tier, category, content, 1.0, 0, now, expires_at, meta_json, now])
@@ -63,24 +63,24 @@ module RubynCode
63
63
  attrs.each do |key, value|
64
64
  case key
65
65
  when :content
66
- sets << "content = ?"
66
+ sets << 'content = ?'
67
67
  params << value
68
68
  when :tier
69
69
  validate_tier!(value)
70
- sets << "tier = ?"
70
+ sets << 'tier = ?'
71
71
  params << value
72
72
  when :category
73
73
  validate_category!(value) if value
74
- sets << "category = ?"
74
+ sets << 'category = ?'
75
75
  params << value
76
76
  when :metadata
77
- sets << "metadata = ?"
77
+ sets << 'metadata = ?'
78
78
  params << JSON.generate(value)
79
79
  when :expires_at
80
- sets << "expires_at = ?"
80
+ sets << 'expires_at = ?'
81
81
  params << value
82
82
  when :relevance_score
83
- sets << "relevance_score = ?"
83
+ sets << 'relevance_score = ?'
84
84
  params << value.to_f
85
85
  end
86
86
  end
@@ -101,23 +101,23 @@ module RubynCode
101
101
  # @param id [String]
102
102
  # @return [void]
103
103
  def delete(id)
104
- @db.execute("DELETE FROM memories WHERE id = ? AND project_path = ?", [id, @project_path])
104
+ @db.execute('DELETE FROM memories WHERE id = ? AND project_path = ?', [id, @project_path])
105
105
  end
106
106
 
107
107
  # Removes all memories whose expires_at is in the past.
108
108
  #
109
109
  # @return [Integer] number of expired memories deleted
110
110
  def expire_old!
111
- now = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
111
+ now = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S')
112
112
 
113
113
  expired_ids = @db.query(
114
- "SELECT id FROM memories WHERE project_path = ? AND expires_at IS NOT NULL AND expires_at < ?",
114
+ 'SELECT id FROM memories WHERE project_path = ? AND expires_at IS NOT NULL AND expires_at < ?',
115
115
  [@project_path, now]
116
- ).to_a.map { |row| row["id"] }
116
+ ).to_a.map { |row| row['id'] }
117
117
 
118
118
  return 0 if expired_ids.empty?
119
119
 
120
- placeholders = (["?"] * expired_ids.size).join(", ")
120
+ placeholders = (['?'] * expired_ids.size).join(', ')
121
121
  @db.execute(
122
122
  "DELETE FROM memories WHERE id IN (#{placeholders}) AND project_path = ?",
123
123
  expired_ids + [@project_path]
@@ -132,7 +132,7 @@ module RubynCode
132
132
  # @param decay_rate [Float] amount to subtract from relevance_score (default 0.01)
133
133
  # @return [void]
134
134
  def decay!(decay_rate: 0.01)
135
- cutoff = (Time.now.utc - 86_400).strftime("%Y-%m-%d %H:%M:%S") # 24 hours ago
135
+ cutoff = (Time.now.utc - 86_400).strftime('%Y-%m-%d %H:%M:%S') # 24 hours ago
136
136
 
137
137
  @db.execute(<<~SQL, [decay_rate, @project_path, cutoff])
138
138
  UPDATE memories
@@ -0,0 +1,19 @@
1
+ # Layer 13: Observability
2
+
3
+ Token counting, cost tracking, and budget enforcement.
4
+
5
+ ## Classes
6
+
7
+ - **`TokenCounter`** — Estimates token counts for messages and tool results.
8
+ Used by `Context::Manager` for compaction decisions and by `CostCalculator` for pricing.
9
+
10
+ - **`CostCalculator`** — Computes cost per API call based on model, input/output tokens.
11
+ Persists records to the `cost_records` table.
12
+
13
+ - **`BudgetEnforcer`** — Enforces per-session and global budget caps. Raises
14
+ `BudgetExceededError` when the limit is hit. Checked in `Agent::Loop` before each API call.
15
+
16
+ - **`UsageReporter`** — Generates usage summaries: tokens used, cost breakdown, session stats.
17
+ Powers the `/cost` and `/budget` slash commands.
18
+
19
+ - **`Models`** — Data objects for cost records.
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
- require "time"
5
- require_relative "models"
6
- require_relative "cost_calculator"
3
+ require 'securerandom'
4
+ require 'time'
5
+ require_relative 'models'
6
+ require_relative 'cost_calculator'
7
7
 
8
8
  module RubynCode
9
9
  module Observability
@@ -13,7 +13,7 @@ module RubynCode
13
13
  DEFAULT_SESSION_LIMIT = 5.00
14
14
  DEFAULT_DAILY_LIMIT = 10.00
15
15
 
16
- TABLE_NAME = "cost_records"
16
+ TABLE_NAME = 'cost_records'
17
17
 
18
18
  # @param db [DB::Connection] database connection
19
19
  # @param session_id [String] current session identifier
@@ -37,7 +37,8 @@ module RubynCode
37
37
  # @param cache_write_tokens [Integer] cache-write token count
38
38
  # @param request_type [String] the type of request (e.g., "chat", "compact")
39
39
  # @return [CostRecord] the persisted cost record
40
- def record!(model:, input_tokens:, output_tokens:, cache_read_tokens: 0, cache_write_tokens: 0, request_type: "chat")
40
+ def record!(model:, input_tokens:, output_tokens:, cache_read_tokens: 0, cache_write_tokens: 0,
41
+ request_type: 'chat')
41
42
  cost = CostCalculator.calculate(
42
43
  model: model,
43
44
  input_tokens: input_tokens,
@@ -51,8 +52,8 @@ module RubynCode
51
52
 
52
53
  @db.execute(
53
54
  "INSERT INTO #{TABLE_NAME} (id, session_id, model, input_tokens, output_tokens, " \
54
- "cache_read_tokens, cache_write_tokens, cost_usd, request_type, created_at) " \
55
- "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
55
+ 'cache_read_tokens, cache_write_tokens, cost_usd, request_type, created_at) ' \
56
+ 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
56
57
  [id, @session_id, model, input_tokens, output_tokens,
57
58
  cache_read_tokens, cache_write_tokens, cost, request_type, now]
58
59
  )
@@ -79,14 +80,14 @@ module RubynCode
79
80
  sc = session_cost
80
81
  if sc >= @session_limit
81
82
  raise BudgetExceededError,
82
- "Session budget exceeded: $#{"%.4f" % sc} >= $#{"%.2f" % @session_limit} limit"
83
+ "Session budget exceeded: $#{'%.4f' % sc} >= $#{format('%.2f', @session_limit)} limit"
83
84
  end
84
85
 
85
86
  dc = daily_cost
86
- if dc >= @daily_limit
87
- raise BudgetExceededError,
88
- "Daily budget exceeded: $#{"%.4f" % dc} >= $#{"%.2f" % @daily_limit} limit"
89
- end
87
+ return unless dc >= @daily_limit
88
+
89
+ raise BudgetExceededError,
90
+ "Daily budget exceeded: $#{'%.4f' % dc} >= $#{format('%.2f', @daily_limit)} limit"
90
91
  end
91
92
 
92
93
  # Returns the total cost accumulated in the current session.
@@ -104,7 +105,7 @@ module RubynCode
104
105
  #
105
106
  # @return [Float] total daily cost in USD
106
107
  def daily_cost
107
- today = Time.now.utc.strftime("%Y-%m-%d")
108
+ today = Time.now.utc.strftime('%Y-%m-%d')
108
109
  rows = @db.query(
109
110
  "SELECT COALESCE(SUM(cost_usd), 0.0) AS total FROM #{TABLE_NAME} WHERE created_at >= ?",
110
111
  ["#{today}T00:00:00Z"]
@@ -152,7 +153,7 @@ module RubynCode
152
153
  return 0.0 if rows.nil? || rows.empty?
153
154
 
154
155
  row = rows.first
155
- (row["total"] || row[:total] || 0.0).to_f
156
+ (row['total'] || row[:total] || 0.0).to_f
156
157
  end
157
158
  end
158
159
  end
@@ -9,9 +9,9 @@ module RubynCode
9
9
  module CostCalculator
10
10
  # Per-million-token rates: { model_prefix => [input_rate, output_rate] }
11
11
  PRICING = {
12
- "claude-haiku-4-5" => [1.00, 5.00],
13
- "claude-sonnet-4-20250514" => [3.00, 15.00],
14
- "claude-opus-4-20250514" => [15.00, 75.00]
12
+ 'claude-haiku-4-5' => [1.00, 5.00],
13
+ 'claude-sonnet-4-20250514' => [3.00, 15.00],
14
+ 'claude-opus-4-20250514' => [15.00, 75.00]
15
15
  }.freeze
16
16
 
17
17
  CACHE_READ_DISCOUNT = 0.1
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
3
+ require 'json'
4
4
 
5
5
  module RubynCode
6
6
  module Observability
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "time"
3
+ require 'time'
4
4
 
5
5
  module RubynCode
6
6
  module Observability
@@ -27,21 +27,21 @@ module RubynCode
27
27
 
28
28
  return "No usage data for session #{session_id}." if rows.empty?
29
29
 
30
- total_input = rows.sum { |r| fetch_int(r, "input_tokens") }
31
- total_output = rows.sum { |r| fetch_int(r, "output_tokens") }
32
- total_cost = rows.sum { |r| fetch_float(r, "cost_usd") }
30
+ total_input = rows.sum { |r| fetch_int(r, 'input_tokens') }
31
+ total_output = rows.sum { |r| fetch_int(r, 'output_tokens') }
32
+ total_cost = rows.sum { |r| fetch_float(r, 'cost_usd') }
33
33
  turns = rows.size
34
34
  avg_cost = turns.positive? ? total_cost / turns : 0.0
35
35
 
36
36
  lines = [
37
- header("Session Summary"),
38
- field("Session", session_id),
39
- field("Turns", turns.to_s),
40
- field("Input tokens", format_number(total_input)),
41
- field("Output tokens", format_number(total_output)),
42
- field("Total tokens", format_number(total_input + total_output)),
43
- field("Total cost", format_usd(total_cost)),
44
- field("Avg cost/turn", format_usd(avg_cost))
37
+ header('Session Summary'),
38
+ field('Session', session_id),
39
+ field('Turns', turns.to_s),
40
+ field('Input tokens', format_number(total_input)),
41
+ field('Output tokens', format_number(total_output)),
42
+ field('Total tokens', format_number(total_input + total_output)),
43
+ field('Total cost', format_usd(total_cost)),
44
+ field('Avg cost/turn', format_usd(avg_cost))
45
45
  ]
46
46
 
47
47
  lines.join("\n")
@@ -51,29 +51,29 @@ module RubynCode
51
51
  #
52
52
  # @return [String] multi-line formatted summary
53
53
  def daily_summary
54
- today = Time.now.utc.strftime("%Y-%m-%d")
54
+ today = Time.now.utc.strftime('%Y-%m-%d')
55
55
  rows = @db.query(
56
- "SELECT session_id, SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens, " \
56
+ 'SELECT session_id, SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens, ' \
57
57
  "SUM(cost_usd) AS cost_usd, COUNT(*) AS turns FROM #{TABLE_NAME} " \
58
- "WHERE created_at >= ? GROUP BY session_id",
58
+ 'WHERE created_at >= ? GROUP BY session_id',
59
59
  ["#{today}T00:00:00Z"]
60
60
  ).to_a
61
61
 
62
- return "No usage data for today." if rows.empty?
62
+ return 'No usage data for today.' if rows.empty?
63
63
 
64
- total_input = rows.sum { |r| fetch_int(r, "input_tokens") }
65
- total_output = rows.sum { |r| fetch_int(r, "output_tokens") }
66
- total_cost = rows.sum { |r| fetch_float(r, "cost_usd") }
67
- total_turns = rows.sum { |r| fetch_int(r, "turns") }
64
+ total_input = rows.sum { |r| fetch_int(r, 'input_tokens') }
65
+ total_output = rows.sum { |r| fetch_int(r, 'output_tokens') }
66
+ total_cost = rows.sum { |r| fetch_float(r, 'cost_usd') }
67
+ total_turns = rows.sum { |r| fetch_int(r, 'turns') }
68
68
  sessions = rows.size
69
69
 
70
70
  lines = [
71
71
  header("Daily Summary (#{today})"),
72
- field("Sessions", sessions.to_s),
73
- field("Total turns", total_turns.to_s),
74
- field("Input tokens", format_number(total_input)),
75
- field("Output tokens", format_number(total_output)),
76
- field("Total cost", format_usd(total_cost))
72
+ field('Sessions', sessions.to_s),
73
+ field('Total turns', total_turns.to_s),
74
+ field('Input tokens', format_number(total_input)),
75
+ field('Output tokens', format_number(total_output)),
76
+ field('Total cost', format_usd(total_cost))
77
77
  ]
78
78
 
79
79
  lines.join("\n")
@@ -85,22 +85,22 @@ module RubynCode
85
85
  # @return [String] multi-line formatted breakdown
86
86
  def model_breakdown(session_id)
87
87
  rows = @db.query(
88
- "SELECT model, SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens, " \
88
+ 'SELECT model, SUM(input_tokens) AS input_tokens, SUM(output_tokens) AS output_tokens, ' \
89
89
  "SUM(cost_usd) AS cost_usd, COUNT(*) AS calls FROM #{TABLE_NAME} " \
90
- "WHERE session_id = ? GROUP BY model ORDER BY cost_usd DESC",
90
+ 'WHERE session_id = ? GROUP BY model ORDER BY cost_usd DESC',
91
91
  [session_id]
92
92
  ).to_a
93
93
 
94
94
  return "No usage data for session #{session_id}." if rows.empty?
95
95
 
96
- lines = [header("Cost by Model")]
96
+ lines = [header('Cost by Model')]
97
97
 
98
98
  rows.each do |row|
99
- model = row["model"] || row[:model]
100
- cost = fetch_float(row, "cost_usd")
101
- calls = fetch_int(row, "calls")
102
- input_t = fetch_int(row, "input_tokens")
103
- output_t = fetch_int(row, "output_tokens")
99
+ model = row['model'] || row[:model]
100
+ cost = fetch_float(row, 'cost_usd')
101
+ calls = fetch_int(row, 'calls')
102
+ input_t = fetch_int(row, 'input_tokens')
103
+ output_t = fetch_int(row, 'output_tokens')
104
104
 
105
105
  lines << " #{@formatter.pastel.bold(model)}"
106
106
  lines << " Calls: #{calls} | Input: #{format_number(input_t)} | Output: #{format_number(output_t)} | Cost: #{format_usd(cost)}"
@@ -112,7 +112,7 @@ module RubynCode
112
112
  private
113
113
 
114
114
  def header(title)
115
- bar = @formatter.pastel.dim("-" * 40)
115
+ bar = @formatter.pastel.dim('-' * 40)
116
116
  "#{bar}\n #{@formatter.pastel.bold(title)}\n#{bar}"
117
117
  end
118
118
 
@@ -121,7 +121,7 @@ module RubynCode
121
121
  end
122
122
 
123
123
  def format_usd(amount)
124
- "$%.4f" % amount
124
+ '$%.4f' % amount
125
125
  end
126
126
 
127
127
  def format_number(n)
@@ -0,0 +1,11 @@
1
+ # Output Layer
2
+
3
+ Formatting utilities for terminal display.
4
+
5
+ ## Classes
6
+
7
+ - **`Formatter`** — General-purpose output formatting. Wraps text, formats tables,
8
+ renders markdown-flavored content for the terminal.
9
+
10
+ - **`DiffRenderer`** — Renders unified diffs with color highlighting. Used by `edit_file`
11
+ and `review_pr` tools to show what changed.
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "pastel"
3
+ require 'pastel'
4
4
 
5
5
  module RubynCode
6
6
  module Output
@@ -30,17 +30,17 @@ module RubynCode
30
30
  # @param new_text [String] the modified text
31
31
  # @param filename [String] the filename to display in the diff header
32
32
  # @return [String] the rendered, colorized diff output
33
- def render(old_text, new_text, filename: "file")
33
+ def render(old_text, new_text, filename: 'file')
34
34
  old_lines = old_text.lines.map(&:chomp)
35
35
  new_lines = new_text.lines.map(&:chomp)
36
36
 
37
37
  hunks = compute_hunks(old_lines, new_lines)
38
- return pastel.dim("No differences found.") if hunks.empty?
38
+ return pastel.dim('No differences found.') if hunks.empty?
39
39
 
40
40
  parts = []
41
41
  parts << render_header(filename)
42
42
  hunks.each { |hunk| parts << render_hunk(hunk) }
43
- parts << ""
43
+ parts << ''
44
44
 
45
45
  result = parts.join("\n")
46
46
  $stdout.puts(result)
@@ -128,7 +128,7 @@ module RubynCode
128
128
  # Groups raw diff operations into hunks with surrounding context lines.
129
129
  def group_into_hunks(raw_diff, old_lines, new_lines)
130
130
  # Identify change indices (non-equal operations)
131
- change_indices = raw_diff.each_index.select { |idx| raw_diff[idx][0] != :equal }
131
+ change_indices = raw_diff.each_index.reject { |idx| raw_diff[idx][0] == :equal }
132
132
  return [] if change_indices.empty?
133
133
 
134
134
  # Group changes that are within context_lines of each other
@@ -136,7 +136,7 @@ module RubynCode
136
136
  current_group = [change_indices.first]
137
137
 
138
138
  change_indices.drop(1).each do |idx|
139
- if idx - current_group.last <= @context_lines * 2 + 1
139
+ if idx - current_group.last <= (@context_lines * 2) + 1
140
140
  current_group << idx
141
141
  else
142
142
  groups << current_group
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "pastel"
4
- require "rouge"
3
+ require 'pastel'
4
+ require 'rouge'
5
5
 
6
6
  module RubynCode
7
7
  module Output
@@ -39,7 +39,7 @@ module RubynCode
39
39
  output pastel.bold(message)
40
40
  end
41
41
 
42
- def code_block(code, language: "ruby")
42
+ def code_block(code, language: 'ruby')
43
43
  lexer = find_lexer(language)
44
44
  formatter = Rouge::Formatters::Terminal256.new(theme: Rouge::Themes::Monokai.new)
45
45
 
@@ -107,7 +107,7 @@ module RubynCode
107
107
  def truncate(text, max_length)
108
108
  return text if text.length <= max_length
109
109
 
110
- "#{text[0, max_length]}#{pastel.dim("... (truncated)")}"
110
+ "#{text[0, max_length]}#{pastel.dim('... (truncated)')}"
111
111
  end
112
112
 
113
113
  def find_lexer(language)
@@ -0,0 +1,17 @@
1
+ # Layer 3: Permissions
2
+
3
+ Tiered permission system controlling which tools the agent can use.
4
+
5
+ ## Classes
6
+
7
+ - **`Tier`** — Defines permission tiers (e.g. `:readonly`, `:edit`, `:admin`).
8
+ Each tier grants access to a set of tools. Higher tiers include all lower-tier tools.
9
+
10
+ - **`Policy`** — Evaluates whether a tool call is allowed given the current tier.
11
+ Consulted by `Tools::Executor` before every tool invocation.
12
+
13
+ - **`DenyList`** — Explicit tool deny list. Overrides tier permissions.
14
+ Configurable per-project via `.rubyn-code.yml`.
15
+
16
+ - **`Prompter`** — Asks the user for permission when a tool requires escalation.
17
+ Renders the tool name and arguments, waits for yes/no confirmation.