rubyn-code 0.2.2 → 0.3.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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +91 -3
  3. data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
  4. data/lib/rubyn_code/agent/conversation.rb +55 -56
  5. data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
  6. data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
  7. data/lib/rubyn_code/agent/llm_caller.rb +149 -0
  8. data/lib/rubyn_code/agent/loop.rb +175 -683
  9. data/lib/rubyn_code/agent/loop_detector.rb +50 -11
  10. data/lib/rubyn_code/agent/prompts.rb +109 -0
  11. data/lib/rubyn_code/agent/response_modes.rb +111 -0
  12. data/lib/rubyn_code/agent/response_parser.rb +111 -0
  13. data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
  14. data/lib/rubyn_code/agent/tool_processor.rb +158 -0
  15. data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
  16. data/lib/rubyn_code/auth/oauth.rb +80 -64
  17. data/lib/rubyn_code/auth/server.rb +21 -24
  18. data/lib/rubyn_code/auth/token_store.rb +31 -44
  19. data/lib/rubyn_code/autonomous/daemon.rb +29 -18
  20. data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
  21. data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
  22. data/lib/rubyn_code/background/worker.rb +64 -76
  23. data/lib/rubyn_code/cli/app.rb +128 -114
  24. data/lib/rubyn_code/cli/commands/model.rb +75 -18
  25. data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
  26. data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
  27. data/lib/rubyn_code/cli/renderer.rb +109 -60
  28. data/lib/rubyn_code/cli/repl.rb +42 -373
  29. data/lib/rubyn_code/cli/repl_commands.rb +176 -0
  30. data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
  31. data/lib/rubyn_code/cli/repl_setup.rb +145 -0
  32. data/lib/rubyn_code/cli/setup.rb +6 -2
  33. data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
  34. data/lib/rubyn_code/cli/version_check.rb +28 -11
  35. data/lib/rubyn_code/config/defaults.rb +10 -0
  36. data/lib/rubyn_code/config/project_profile.rb +185 -0
  37. data/lib/rubyn_code/config/settings.rb +100 -1
  38. data/lib/rubyn_code/context/auto_compact.rb +1 -1
  39. data/lib/rubyn_code/context/context_budget.rb +167 -0
  40. data/lib/rubyn_code/context/decision_compactor.rb +99 -0
  41. data/lib/rubyn_code/context/manager.rb +7 -5
  42. data/lib/rubyn_code/context/micro_compact.rb +29 -19
  43. data/lib/rubyn_code/context/schema_filter.rb +64 -0
  44. data/lib/rubyn_code/db/connection.rb +31 -26
  45. data/lib/rubyn_code/db/migrator.rb +44 -28
  46. data/lib/rubyn_code/hooks/built_in.rb +14 -10
  47. data/lib/rubyn_code/index/codebase_index.rb +245 -0
  48. data/lib/rubyn_code/learning/extractor.rb +65 -82
  49. data/lib/rubyn_code/learning/injector.rb +22 -23
  50. data/lib/rubyn_code/learning/instinct.rb +71 -42
  51. data/lib/rubyn_code/learning/shortcut.rb +95 -0
  52. data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
  53. data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
  54. data/lib/rubyn_code/llm/adapters/base.rb +35 -0
  55. data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
  56. data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
  57. data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
  58. data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
  59. data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
  60. data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
  61. data/lib/rubyn_code/llm/client.rb +55 -252
  62. data/lib/rubyn_code/llm/model_router.rb +237 -0
  63. data/lib/rubyn_code/llm/streaming.rb +4 -227
  64. data/lib/rubyn_code/mcp/client.rb +1 -1
  65. data/lib/rubyn_code/mcp/config.rb +9 -12
  66. data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
  67. data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
  68. data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
  69. data/lib/rubyn_code/memory/session_persistence.rb +59 -58
  70. data/lib/rubyn_code/memory/store.rb +42 -55
  71. data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
  72. data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
  73. data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
  74. data/lib/rubyn_code/observability/token_analytics.rb +130 -0
  75. data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
  76. data/lib/rubyn_code/output/diff_renderer.rb +102 -77
  77. data/lib/rubyn_code/output/formatter.rb +11 -11
  78. data/lib/rubyn_code/permissions/policy.rb +11 -13
  79. data/lib/rubyn_code/permissions/prompter.rb +8 -9
  80. data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
  81. data/lib/rubyn_code/skills/document.rb +33 -29
  82. data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
  83. data/lib/rubyn_code/sub_agents/runner.rb +20 -25
  84. data/lib/rubyn_code/tasks/dag.rb +25 -24
  85. data/lib/rubyn_code/tools/ask_user.rb +44 -0
  86. data/lib/rubyn_code/tools/background_run.rb +2 -1
  87. data/lib/rubyn_code/tools/base.rb +26 -32
  88. data/lib/rubyn_code/tools/bash.rb +2 -1
  89. data/lib/rubyn_code/tools/edit_file.rb +74 -18
  90. data/lib/rubyn_code/tools/executor.rb +74 -24
  91. data/lib/rubyn_code/tools/file_cache.rb +95 -0
  92. data/lib/rubyn_code/tools/git_commit.rb +12 -10
  93. data/lib/rubyn_code/tools/git_log.rb +12 -10
  94. data/lib/rubyn_code/tools/glob.rb +23 -7
  95. data/lib/rubyn_code/tools/grep.rb +2 -1
  96. data/lib/rubyn_code/tools/load_skill.rb +13 -6
  97. data/lib/rubyn_code/tools/memory_search.rb +14 -13
  98. data/lib/rubyn_code/tools/memory_write.rb +2 -1
  99. data/lib/rubyn_code/tools/output_compressor.rb +185 -0
  100. data/lib/rubyn_code/tools/read_file.rb +11 -6
  101. data/lib/rubyn_code/tools/review_pr.rb +127 -80
  102. data/lib/rubyn_code/tools/run_specs.rb +26 -15
  103. data/lib/rubyn_code/tools/schema.rb +4 -10
  104. data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
  105. data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
  106. data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
  107. data/lib/rubyn_code/tools/task.rb +17 -17
  108. data/lib/rubyn_code/tools/web_fetch.rb +62 -47
  109. data/lib/rubyn_code/tools/web_search.rb +66 -48
  110. data/lib/rubyn_code/tools/write_file.rb +59 -1
  111. data/lib/rubyn_code/version.rb +1 -1
  112. data/lib/rubyn_code.rb +40 -1
  113. data/skills/rubyn_self_test.md +121 -0
  114. metadata +53 -1
@@ -11,6 +11,11 @@ module RubynCode
11
11
  # - Delegates #execute to the MCP client's #call_tool
12
12
  # - Registers itself with Tools::Registry
13
13
  module ToolBridge
14
+ JSON_TYPE_MAP = {
15
+ 'string' => :string, 'integer' => :integer, 'number' => :number,
16
+ 'boolean' => :boolean, 'array' => :array, 'object' => :object
17
+ }.freeze
18
+
14
19
  class << self
15
20
  # Discovers tools from an MCP client and creates corresponding
16
21
  # RubynCode tool classes.
@@ -35,12 +40,21 @@ module RubynCode
35
40
  # @return [Class] the newly created and registered tool class
36
41
  def build_tool_class(mcp_client, tool_def)
37
42
  remote_name = tool_def['name']
38
- tool_name = "mcp_#{sanitize_name(remote_name)}"
39
- description = tool_def['description'] || "MCP tool: #{remote_name}"
40
- input_schema = tool_def['inputSchema'] || {}
41
- parameters = build_parameters_from_schema(input_schema)
43
+ attrs = {
44
+ tool_name: "mcp_#{sanitize_name(remote_name)}",
45
+ description: tool_def['description'] || "MCP tool: #{remote_name}",
46
+ parameters: build_parameters_from_schema(tool_def['inputSchema'] || {})
47
+ }
48
+ klass = create_tool_class(attrs[:tool_name], attrs[:description], attrs[:parameters], mcp_client,
49
+ remote_name)
50
+ Tools::Registry.register(klass)
51
+ klass
52
+ end
42
53
 
43
- klass = Class.new(Tools::Base) do
54
+ def create_tool_class(tool_name, description, parameters, mcp_client, remote_name) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength -- dynamic class creation requires setting many constants
55
+ bridge = self
56
+
57
+ Class.new(Tools::Base) do
44
58
  const_set(:TOOL_NAME, tool_name)
45
59
  const_set(:DESCRIPTION, description)
46
60
  const_set(:PARAMETERS, parameters)
@@ -60,62 +74,25 @@ module RubynCode
60
74
  define_method(:format_result) do |result|
61
75
  case result
62
76
  when Hash
63
- if result.key?('content')
64
- extract_content(result['content'])
65
- else
66
- JSON.generate(result)
67
- end
68
- when String
69
- result
70
- else
71
- result.to_s
77
+ result.key?('content') ? extract_content(result['content']) : JSON.generate(result)
78
+ when String then result
79
+ else result.to_s
72
80
  end
73
81
  end
74
82
 
75
83
  define_method(:extract_content) do |content|
76
- Array(content).map do |block|
77
- case block['type']
78
- when 'text'
79
- block['text']
80
- when 'image'
81
- "[image: #{block['mimeType']}]"
82
- when 'resource'
83
- block.dig('resource', 'text') || "[resource: #{block.dig('resource', 'uri')}]"
84
- else
85
- block.to_s
86
- end
87
- end.join("\n")
88
- end
89
- end
90
-
91
- # Build parameter definitions from JSON Schema
92
- klass.define_singleton_method(:build_parameters) do |schema|
93
- properties = schema['properties'] || {}
94
- required = schema['required'] || []
95
-
96
- properties.each_with_object({}) do |(name, prop), params|
97
- params[name.to_sym] = {
98
- type: map_json_type(prop['type']),
99
- description: prop['description'] || '',
100
- required: required.include?(name)
101
- }
84
+ Array(content).map { |block| bridge.send(:format_mcp_block, block) }.join("\n")
102
85
  end
103
86
  end
87
+ end
104
88
 
105
- klass.define_singleton_method(:map_json_type) do |json_type|
106
- case json_type
107
- when 'string' then :string
108
- when 'integer' then :integer
109
- when 'number' then :number
110
- when 'boolean' then :boolean
111
- when 'array' then :array
112
- when 'object' then :object
113
- else :string
114
- end
89
+ def format_mcp_block(block)
90
+ case block['type']
91
+ when 'text' then block['text']
92
+ when 'image' then "[image: #{block['mimeType']}]"
93
+ when 'resource' then block.dig('resource', 'text') || "[resource: #{block.dig('resource', 'uri')}]"
94
+ else block.to_s
115
95
  end
116
-
117
- Tools::Registry.register(klass)
118
- klass
119
96
  end
120
97
 
121
98
  # Builds parameter definitions from a JSON Schema.
@@ -140,15 +117,7 @@ module RubynCode
140
117
  # @param json_type [String]
141
118
  # @return [Symbol]
142
119
  def map_json_type(json_type)
143
- case json_type
144
- when 'string' then :string
145
- when 'integer' then :integer
146
- when 'number' then :number
147
- when 'boolean' then :boolean
148
- when 'array' then :array
149
- when 'object' then :object
150
- else :string
151
- end
120
+ JSON_TYPE_MAP.fetch(json_type, :string)
152
121
  end
153
122
 
154
123
  # Sanitizes a tool name for use as a Ruby-friendly identifier.
@@ -16,19 +16,21 @@ module RubynCode
16
16
 
17
17
  # Persists a complete session snapshot.
18
18
  #
19
- # @param session_id [String] unique session identifier
20
- # @param project_path [String] project this session belongs to
21
- # @param messages [Array<Hash>] the conversation messages
22
- # @param title [String, nil] human-readable session title
23
- # @param model [String, nil] LLM model used
24
- # @param metadata [Hash] arbitrary metadata
19
+ # @param attrs [Hash] session attributes:
20
+ # :session_id, :project_path, :messages (required);
21
+ # :title, :model, :metadata (optional)
25
22
  # @return [void]
26
- def save_session(session_id:, project_path:, messages:, title: nil, model: nil, metadata: {})
23
+ def save_session(session_id:, project_path:, messages:, **opts)
27
24
  now = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S')
28
25
  messages_json = JSON.generate(messages)
29
- meta_json = JSON.generate(metadata)
26
+ meta_json = JSON.generate(opts.fetch(:metadata, {}))
27
+ title = opts[:title]
28
+ model = opts[:model]
30
29
 
31
- @db.execute(<<~SQL, [session_id, project_path, title, model, messages_json, 'active', meta_json, now, now, messages_json, title, model, meta_json, now])
30
+ insert_params = [session_id, project_path, title, model, messages_json, 'active', meta_json, now, now]
31
+ update_params = [messages_json, title, model, meta_json, now]
32
+
33
+ @db.execute(<<~SQL, insert_params + update_params)
32
34
  INSERT INTO sessions (id, project_path, title, model, messages, status, metadata, created_at, updated_at)
33
35
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
34
36
  ON CONFLICT(id) DO UPDATE SET
@@ -71,20 +73,7 @@ module RubynCode
71
73
  # @param limit [Integer] maximum results (default 20)
72
74
  # @return [Array<Hash>] session summaries (without full messages)
73
75
  def list_sessions(project_path: nil, status: nil, limit: 20)
74
- conditions = []
75
- params = []
76
-
77
- if project_path
78
- conditions << 'project_path = ?'
79
- params << project_path
80
- end
81
-
82
- if status
83
- conditions << 'status = ?'
84
- params << status
85
- end
86
-
87
- where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
76
+ where_clause, params = build_list_filters(project_path, status)
88
77
  params << limit
89
78
 
90
79
  rows = @db.query(<<~SQL, params).to_a
@@ -95,18 +84,7 @@ module RubynCode
95
84
  LIMIT ?
96
85
  SQL
97
86
 
98
- rows.map do |row|
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']
108
- }
109
- end
87
+ rows.map { |row| row_to_session_summary(row) }
110
88
  end
111
89
 
112
90
  # Updates session attributes.
@@ -117,29 +95,7 @@ module RubynCode
117
95
  def update_session(session_id, **attrs)
118
96
  return if attrs.empty?
119
97
 
120
- sets = []
121
- params = []
122
-
123
- attrs.each do |key, value|
124
- case key
125
- when :title
126
- sets << 'title = ?'
127
- params << value
128
- when :status
129
- sets << 'status = ?'
130
- params << value
131
- when :model
132
- sets << 'model = ?'
133
- params << value
134
- when :metadata
135
- sets << 'metadata = ?'
136
- params << JSON.generate(value)
137
- when :messages
138
- sets << 'messages = ?'
139
- params << JSON.generate(value)
140
- end
141
- end
142
-
98
+ sets, params = build_update_clauses(attrs)
143
99
  return if sets.empty?
144
100
 
145
101
  sets << 'updated_at = ?'
@@ -157,8 +113,53 @@ module RubynCode
157
113
  @db.execute('DELETE FROM sessions WHERE id = ?', [session_id])
158
114
  end
159
115
 
116
+ JSON_ATTRS = %i[metadata messages].freeze
117
+ SIMPLE_ATTRS = %i[title status model].freeze
118
+
160
119
  private
161
120
 
121
+ def build_list_filters(project_path, status)
122
+ conditions = []
123
+ params = []
124
+ if project_path
125
+ conditions << 'project_path = ?'
126
+ params << project_path
127
+ end
128
+ if status
129
+ conditions << 'status = ?'
130
+ params << status
131
+ end
132
+ where_clause = conditions.empty? ? '' : "WHERE #{conditions.join(' AND ')}"
133
+ [where_clause, params]
134
+ end
135
+
136
+ def row_to_session_summary(row)
137
+ {
138
+ id: row['id'],
139
+ project_path: row['project_path'],
140
+ title: row['title'],
141
+ model: row['model'],
142
+ status: row['status'],
143
+ metadata: parse_json_hash(row['metadata']),
144
+ created_at: row['created_at'],
145
+ updated_at: row['updated_at']
146
+ }
147
+ end
148
+
149
+ def build_update_clauses(attrs)
150
+ sets = []
151
+ params = []
152
+
153
+ attrs.each do |key, value|
154
+ next unless SIMPLE_ATTRS.include?(key) || JSON_ATTRS.include?(key)
155
+
156
+ sets << "#{key} = ?"
157
+ params << (JSON_ATTRS.include?(key) ? JSON.generate(value) : value)
158
+ end
159
+
160
+ [sets, params]
161
+ end
162
+
162
163
  def ensure_table
163
164
  @db.execute(<<~SQL)
164
165
  CREATE TABLE IF NOT EXISTS sessions (
@@ -57,34 +57,7 @@ module RubynCode
57
57
  def update(id, **attrs)
58
58
  return if attrs.empty?
59
59
 
60
- sets = []
61
- params = []
62
-
63
- attrs.each do |key, value|
64
- case key
65
- when :content
66
- sets << 'content = ?'
67
- params << value
68
- when :tier
69
- validate_tier!(value)
70
- sets << 'tier = ?'
71
- params << value
72
- when :category
73
- validate_category!(value) if value
74
- sets << 'category = ?'
75
- params << value
76
- when :metadata
77
- sets << 'metadata = ?'
78
- params << JSON.generate(value)
79
- when :expires_at
80
- sets << 'expires_at = ?'
81
- params << value
82
- when :relevance_score
83
- sets << 'relevance_score = ?'
84
- params << value.to_f
85
- end
86
- end
87
-
60
+ sets, params = build_memory_update(attrs)
88
61
  return if sets.empty?
89
62
 
90
63
  params << id
@@ -92,8 +65,6 @@ module RubynCode
92
65
  "UPDATE memories SET #{sets.join(', ')} WHERE id = ? AND project_path = '#{@project_path}'",
93
66
  params
94
67
  )
95
-
96
- # Content changes are picked up by LIKE-based search — no FTS sync needed
97
68
  end
98
69
 
99
70
  # Deletes a memory and its FTS index entry.
@@ -142,41 +113,57 @@ module RubynCode
142
113
  SQL
143
114
  end
144
115
 
116
+ MEMORY_ATTR_MAP = {
117
+ content: ->(v) { v },
118
+ tier: ->(v) { v },
119
+ category: ->(v) { v },
120
+ metadata: ->(v) { JSON.generate(v) },
121
+ expires_at: ->(v) { v },
122
+ relevance_score: lambda(&:to_f)
123
+ }.freeze
124
+
145
125
  private
146
126
 
127
+ def build_memory_update(attrs)
128
+ sets = []
129
+ params = []
130
+
131
+ attrs.each do |key, value|
132
+ next unless MEMORY_ATTR_MAP.key?(key)
133
+
134
+ validate_tier!(value) if key == :tier
135
+ validate_category!(value) if key == :category && value
136
+
137
+ sets << "#{key} = ?"
138
+ params << MEMORY_ATTR_MAP[key].call(value)
139
+ end
140
+
141
+ [sets, params]
142
+ end
143
+
147
144
  def ensure_tables
145
+ create_memories_table
146
+ create_memories_indexes
147
+ end
148
+
149
+ def create_memories_table
148
150
  @db.execute(<<~SQL)
149
151
  CREATE TABLE IF NOT EXISTS memories (
150
- id TEXT PRIMARY KEY,
151
- project_path TEXT NOT NULL,
152
- tier TEXT NOT NULL DEFAULT 'medium',
153
- category TEXT,
154
- content TEXT NOT NULL,
155
- relevance_score REAL NOT NULL DEFAULT 1.0,
156
- access_count INTEGER NOT NULL DEFAULT 0,
157
- last_accessed_at TEXT,
158
- expires_at TEXT,
159
- metadata TEXT DEFAULT '{}',
160
- created_at TEXT NOT NULL
152
+ id TEXT PRIMARY KEY, project_path TEXT NOT NULL,
153
+ tier TEXT NOT NULL DEFAULT 'medium', category TEXT,
154
+ content TEXT NOT NULL, relevance_score REAL NOT NULL DEFAULT 1.0,
155
+ access_count INTEGER NOT NULL DEFAULT 0, last_accessed_at TEXT,
156
+ expires_at TEXT, metadata TEXT DEFAULT '{}', created_at TEXT NOT NULL
161
157
  )
162
158
  SQL
159
+ end
163
160
 
161
+ def create_memories_indexes
162
+ @db.execute('CREATE INDEX IF NOT EXISTS idx_memories_project_tier ON memories (project_path, tier)')
163
+ @db.execute('CREATE INDEX IF NOT EXISTS idx_memories_project_category ON memories (project_path, category)')
164
164
  @db.execute(<<~SQL)
165
- CREATE INDEX IF NOT EXISTS idx_memories_project_tier
166
- ON memories (project_path, tier)
167
- SQL
168
-
169
- @db.execute(<<~SQL)
170
- CREATE INDEX IF NOT EXISTS idx_memories_project_category
171
- ON memories (project_path, category)
172
- SQL
173
-
174
- @db.execute(<<~SQL)
175
- CREATE INDEX IF NOT EXISTS idx_memories_expires_at
176
- ON memories (expires_at) WHERE expires_at IS NOT NULL
165
+ CREATE INDEX IF NOT EXISTS idx_memories_expires_at ON memories (expires_at) WHERE expires_at IS NOT NULL
177
166
  SQL
178
-
179
- # Search uses LIKE queries — no FTS table needed
180
167
  end
181
168
 
182
169
  # @param tier [String]
@@ -37,39 +37,17 @@ 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,
41
- request_type: 'chat')
42
- cost = CostCalculator.calculate(
43
- model: model,
44
- input_tokens: input_tokens,
45
- output_tokens: output_tokens,
46
- cache_read_tokens: cache_read_tokens,
47
- cache_write_tokens: cache_write_tokens
48
- )
49
-
50
- id = SecureRandom.uuid
51
- now = Time.now.utc.iso8601
40
+ def record!(model:, input_tokens:, output_tokens:, **opts)
41
+ cache_read = opts.fetch(:cache_read_tokens, 0)
42
+ cache_write = opts.fetch(:cache_write_tokens, 0)
43
+ req_type = opts.fetch(:request_type, 'chat')
52
44
 
53
- @db.execute(
54
- "INSERT INTO #{TABLE_NAME} (id, session_id, model, input_tokens, output_tokens, " \
55
- 'cache_read_tokens, cache_write_tokens, cost_usd, request_type, created_at) ' \
56
- 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
57
- [id, @session_id, model, input_tokens, output_tokens,
58
- cache_read_tokens, cache_write_tokens, cost, request_type, now]
45
+ cost = CostCalculator.calculate(
46
+ model: model, input_tokens: input_tokens, output_tokens: output_tokens,
47
+ cache_read_tokens: cache_read, cache_write_tokens: cache_write
59
48
  )
60
49
 
61
- CostRecord.new(
62
- id: id,
63
- session_id: @session_id,
64
- model: model,
65
- input_tokens: input_tokens,
66
- output_tokens: output_tokens,
67
- cache_read_tokens: cache_read_tokens,
68
- cache_write_tokens: cache_write_tokens,
69
- cost_usd: cost,
70
- request_type: request_type,
71
- created_at: now
72
- )
50
+ persist_cost_record(model, input_tokens, output_tokens, cache_read, cache_write, cost, req_type)
73
51
  end
74
52
 
75
53
  # Raises BudgetExceededError if either the session or daily budget is exceeded.
@@ -80,14 +58,16 @@ module RubynCode
80
58
  sc = session_cost
81
59
  if sc >= @session_limit
82
60
  raise BudgetExceededError,
83
- "Session budget exceeded: $#{'%.4f' % sc} >= $#{format('%.2f', @session_limit)} limit"
61
+ format('Session budget exceeded: $%<cost>.4f >= $%<limit>.2f limit',
62
+ cost: sc, limit: @session_limit)
84
63
  end
85
64
 
86
65
  dc = daily_cost
87
66
  return unless dc >= @daily_limit
88
67
 
89
68
  raise BudgetExceededError,
90
- "Daily budget exceeded: $#{'%.4f' % dc} >= $#{format('%.2f', @daily_limit)} limit"
69
+ format('Daily budget exceeded: $%<cost>.4f >= $%<limit>.2f limit',
70
+ cost: dc, limit: @daily_limit)
91
71
  end
92
72
 
93
73
  # Returns the total cost accumulated in the current session.
@@ -124,6 +104,40 @@ module RubynCode
124
104
 
125
105
  private
126
106
 
107
+ CostRecordAttrs = Data.define(:model, :input_tokens, :output_tokens, :cache_read, :cache_write, :cost,
108
+ :req_type)
109
+ private_constant :CostRecordAttrs
110
+
111
+ def persist_cost_record(model, input_tokens, output_tokens, cache_read, cache_write, cost, req_type) # rubocop:disable Metrics/ParameterLists -- maps directly to DB columns
112
+ attrs = CostRecordAttrs.new(model:, input_tokens:, output_tokens:, cache_read:, cache_write:, cost:,
113
+ req_type:)
114
+ insert_cost_record(attrs)
115
+ end
116
+
117
+ def insert_cost_record(attrs)
118
+ record_id = SecureRandom.uuid
119
+ now = Time.now.utc.iso8601
120
+
121
+ @db.execute(
122
+ "INSERT INTO #{TABLE_NAME} (id, session_id, model, input_tokens, output_tokens, " \
123
+ 'cache_read_tokens, cache_write_tokens, cost_usd, request_type, created_at) ' \
124
+ 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
125
+ [record_id, @session_id, attrs.model, attrs.input_tokens, attrs.output_tokens,
126
+ attrs.cache_read, attrs.cache_write, attrs.cost, attrs.req_type, now]
127
+ )
128
+
129
+ build_cost_record(record_id, attrs, now)
130
+ end
131
+
132
+ def build_cost_record(record_id, attrs, now)
133
+ CostRecord.new(
134
+ id: record_id, session_id: @session_id, model: attrs.model,
135
+ input_tokens: attrs.input_tokens, output_tokens: attrs.output_tokens,
136
+ cache_read_tokens: attrs.cache_read, cache_write_tokens: attrs.cache_write,
137
+ cost_usd: attrs.cost, request_type: attrs.req_type, created_at: now
138
+ )
139
+ end
140
+
127
141
  def ensure_table_exists
128
142
  @db.execute(<<~SQL)
129
143
  CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
@@ -9,9 +9,19 @@ module RubynCode
9
9
  module CostCalculator
10
10
  # Per-million-token rates: { model_prefix => [input_rate, output_rate] }
11
11
  PRICING = {
12
+ # Anthropic — Claude 4.6
12
13
  '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]
14
+ 'claude-sonnet-4-6' => [3.00, 15.00],
15
+ 'claude-opus-4-6' => [15.00, 75.00],
16
+ # OpenAI — GPT-5.4
17
+ 'gpt-5.4' => [2.50, 10.00],
18
+ 'gpt-5.4-mini' => [0.15, 0.60],
19
+ 'gpt-5.4-nano' => [0.10, 0.40],
20
+ # OpenAI — legacy
21
+ 'gpt-4o' => [2.50, 10.00],
22
+ 'gpt-4o-mini' => [0.15, 0.60],
23
+ 'o3' => [2.00, 8.00],
24
+ 'o4-mini' => [1.10, 4.40]
15
25
  }.freeze
16
26
 
17
27
  CACHE_READ_DISCOUNT = 0.1
@@ -29,12 +39,10 @@ module RubynCode
29
39
  def calculate(model:, input_tokens:, output_tokens:, cache_read_tokens: 0, cache_write_tokens: 0)
30
40
  input_rate, output_rate = rates_for(model)
31
41
 
32
- input_cost = (input_tokens.to_f / 1_000_000) * input_rate
33
- output_cost = (output_tokens.to_f / 1_000_000) * output_rate
34
- cache_read_cost = (cache_read_tokens.to_f / 1_000_000) * input_rate * CACHE_READ_DISCOUNT
35
- cache_write_cost = (cache_write_tokens.to_f / 1_000_000) * input_rate * CACHE_WRITE_PREMIUM
36
-
37
- input_cost + output_cost + cache_read_cost + cache_write_cost
42
+ token_cost(input_tokens, input_rate) +
43
+ token_cost(output_tokens, output_rate) +
44
+ token_cost(cache_read_tokens, input_rate * CACHE_READ_DISCOUNT) +
45
+ token_cost(cache_write_tokens, input_rate * CACHE_WRITE_PREMIUM)
38
46
  end
39
47
 
40
48
  private
@@ -44,7 +52,15 @@ module RubynCode
44
52
  #
45
53
  # @param model [String]
46
54
  # @return [Array(Float, Float)] [input_rate, output_rate]
55
+ def token_cost(tokens, rate)
56
+ (tokens.to_f / 1_000_000) * rate
57
+ end
58
+
47
59
  def rates_for(model)
60
+ # User-configured pricing takes priority
61
+ custom = config_pricing(model)
62
+ return custom if custom
63
+
48
64
  return PRICING[model] if PRICING.key?(model)
49
65
 
50
66
  # Try prefix match (e.g., "claude-sonnet-4-20250514-v2" matches "claude-sonnet-4-20250514")
@@ -55,6 +71,14 @@ module RubynCode
55
71
  # Conservative fallback: use the most expensive known model
56
72
  PRICING.max_by { |_, rates| rates.first }.last
57
73
  end
74
+
75
+ def config_pricing(model)
76
+ settings = Config::Settings.new
77
+ custom = settings.custom_pricing
78
+ custom[model]
79
+ rescue StandardError
80
+ nil
81
+ end
58
82
  end
59
83
  end
60
84
  end