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.
- checksums.yaml +4 -4
- data/README.md +91 -3
- data/lib/rubyn_code/agent/background_job_handler.rb +71 -0
- data/lib/rubyn_code/agent/conversation.rb +55 -56
- data/lib/rubyn_code/agent/dynamic_tool_schema.rb +99 -0
- data/lib/rubyn_code/agent/feedback_handler.rb +49 -0
- data/lib/rubyn_code/agent/llm_caller.rb +149 -0
- data/lib/rubyn_code/agent/loop.rb +175 -683
- data/lib/rubyn_code/agent/loop_detector.rb +50 -11
- data/lib/rubyn_code/agent/prompts.rb +109 -0
- data/lib/rubyn_code/agent/response_modes.rb +111 -0
- data/lib/rubyn_code/agent/response_parser.rb +111 -0
- data/lib/rubyn_code/agent/system_prompt_builder.rb +205 -0
- data/lib/rubyn_code/agent/tool_processor.rb +158 -0
- data/lib/rubyn_code/agent/usage_tracker.rb +59 -0
- data/lib/rubyn_code/auth/oauth.rb +80 -64
- data/lib/rubyn_code/auth/server.rb +21 -24
- data/lib/rubyn_code/auth/token_store.rb +31 -44
- data/lib/rubyn_code/autonomous/daemon.rb +29 -18
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -4
- data/lib/rubyn_code/autonomous/task_claimer.rb +36 -40
- data/lib/rubyn_code/background/worker.rb +64 -76
- data/lib/rubyn_code/cli/app.rb +128 -114
- data/lib/rubyn_code/cli/commands/model.rb +75 -18
- data/lib/rubyn_code/cli/commands/new_session.rb +45 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +28 -11
- data/lib/rubyn_code/cli/renderer.rb +109 -60
- data/lib/rubyn_code/cli/repl.rb +42 -373
- data/lib/rubyn_code/cli/repl_commands.rb +176 -0
- data/lib/rubyn_code/cli/repl_lifecycle.rb +75 -0
- data/lib/rubyn_code/cli/repl_setup.rb +145 -0
- data/lib/rubyn_code/cli/setup.rb +6 -2
- data/lib/rubyn_code/cli/stream_formatter.rb +56 -49
- data/lib/rubyn_code/cli/version_check.rb +28 -11
- data/lib/rubyn_code/config/defaults.rb +10 -0
- data/lib/rubyn_code/config/project_profile.rb +185 -0
- data/lib/rubyn_code/config/settings.rb +100 -1
- data/lib/rubyn_code/context/auto_compact.rb +1 -1
- data/lib/rubyn_code/context/context_budget.rb +167 -0
- data/lib/rubyn_code/context/decision_compactor.rb +99 -0
- data/lib/rubyn_code/context/manager.rb +7 -5
- data/lib/rubyn_code/context/micro_compact.rb +29 -19
- data/lib/rubyn_code/context/schema_filter.rb +64 -0
- data/lib/rubyn_code/db/connection.rb +31 -26
- data/lib/rubyn_code/db/migrator.rb +44 -28
- data/lib/rubyn_code/hooks/built_in.rb +14 -10
- data/lib/rubyn_code/index/codebase_index.rb +245 -0
- data/lib/rubyn_code/learning/extractor.rb +65 -82
- data/lib/rubyn_code/learning/injector.rb +22 -23
- data/lib/rubyn_code/learning/instinct.rb +71 -42
- data/lib/rubyn_code/learning/shortcut.rb +95 -0
- data/lib/rubyn_code/llm/adapters/anthropic.rb +270 -0
- data/lib/rubyn_code/llm/adapters/anthropic_streaming.rb +215 -0
- data/lib/rubyn_code/llm/adapters/base.rb +35 -0
- data/lib/rubyn_code/llm/adapters/json_parsing.rb +21 -0
- data/lib/rubyn_code/llm/adapters/openai.rb +246 -0
- data/lib/rubyn_code/llm/adapters/openai_compatible.rb +46 -0
- data/lib/rubyn_code/llm/adapters/openai_message_translator.rb +90 -0
- data/lib/rubyn_code/llm/adapters/openai_streaming.rb +141 -0
- data/lib/rubyn_code/llm/adapters/prompt_caching.rb +60 -0
- data/lib/rubyn_code/llm/client.rb +55 -252
- data/lib/rubyn_code/llm/model_router.rb +237 -0
- data/lib/rubyn_code/llm/streaming.rb +4 -227
- data/lib/rubyn_code/mcp/client.rb +1 -1
- data/lib/rubyn_code/mcp/config.rb +9 -12
- data/lib/rubyn_code/mcp/sse_transport.rb +15 -13
- data/lib/rubyn_code/mcp/stdio_transport.rb +16 -18
- data/lib/rubyn_code/mcp/tool_bridge.rb +31 -62
- data/lib/rubyn_code/memory/session_persistence.rb +59 -58
- data/lib/rubyn_code/memory/store.rb +42 -55
- data/lib/rubyn_code/observability/budget_enforcer.rb +46 -32
- data/lib/rubyn_code/observability/cost_calculator.rb +32 -8
- data/lib/rubyn_code/observability/skill_analytics.rb +116 -0
- data/lib/rubyn_code/observability/token_analytics.rb +130 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +79 -61
- data/lib/rubyn_code/output/diff_renderer.rb +102 -77
- data/lib/rubyn_code/output/formatter.rb +11 -11
- data/lib/rubyn_code/permissions/policy.rb +11 -13
- data/lib/rubyn_code/permissions/prompter.rb +8 -9
- data/lib/rubyn_code/protocols/plan_approval.rb +25 -20
- data/lib/rubyn_code/skills/document.rb +33 -29
- data/lib/rubyn_code/skills/ttl_manager.rb +100 -0
- data/lib/rubyn_code/sub_agents/runner.rb +20 -25
- data/lib/rubyn_code/tasks/dag.rb +25 -24
- data/lib/rubyn_code/tools/ask_user.rb +44 -0
- data/lib/rubyn_code/tools/background_run.rb +2 -1
- data/lib/rubyn_code/tools/base.rb +26 -32
- data/lib/rubyn_code/tools/bash.rb +2 -1
- data/lib/rubyn_code/tools/edit_file.rb +74 -18
- data/lib/rubyn_code/tools/executor.rb +74 -24
- data/lib/rubyn_code/tools/file_cache.rb +95 -0
- data/lib/rubyn_code/tools/git_commit.rb +12 -10
- data/lib/rubyn_code/tools/git_log.rb +12 -10
- data/lib/rubyn_code/tools/glob.rb +23 -7
- data/lib/rubyn_code/tools/grep.rb +2 -1
- data/lib/rubyn_code/tools/load_skill.rb +13 -6
- data/lib/rubyn_code/tools/memory_search.rb +14 -13
- data/lib/rubyn_code/tools/memory_write.rb +2 -1
- data/lib/rubyn_code/tools/output_compressor.rb +185 -0
- data/lib/rubyn_code/tools/read_file.rb +11 -6
- data/lib/rubyn_code/tools/review_pr.rb +127 -80
- data/lib/rubyn_code/tools/run_specs.rb +26 -15
- data/lib/rubyn_code/tools/schema.rb +4 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +113 -82
- data/lib/rubyn_code/tools/spawn_teammate.rb +107 -64
- data/lib/rubyn_code/tools/spec_output_parser.rb +118 -0
- data/lib/rubyn_code/tools/task.rb +17 -17
- data/lib/rubyn_code/tools/web_fetch.rb +62 -47
- data/lib/rubyn_code/tools/web_search.rb +66 -48
- data/lib/rubyn_code/tools/write_file.rb +59 -1
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +40 -1
- data/skills/rubyn_self_test.md +121 -0
- 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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
|
20
|
-
#
|
|
21
|
-
#
|
|
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:,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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:,
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
14
|
-
'claude-opus-4-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|