rubyn-code 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +269 -467
- data/db/migrations/009_create_teams.sql +6 -6
- data/db/migrations/011_fix_mailbox_messages_columns.rb +35 -0
- data/db/migrations/012_expand_mailbox_message_types.rb +37 -0
- data/exe/rubyn-code +1 -1
- data/lib/rubyn_code/agent/RUBYN.md +17 -0
- data/lib/rubyn_code/agent/conversation.rb +68 -19
- data/lib/rubyn_code/agent/loop.rb +312 -54
- data/lib/rubyn_code/agent/loop_detector.rb +6 -6
- data/lib/rubyn_code/auth/RUBYN.md +19 -0
- data/lib/rubyn_code/auth/oauth.rb +40 -35
- data/lib/rubyn_code/auth/server.rb +16 -12
- data/lib/rubyn_code/auth/token_store.rb +22 -22
- data/lib/rubyn_code/autonomous/RUBYN.md +14 -0
- data/lib/rubyn_code/autonomous/daemon.rb +115 -79
- data/lib/rubyn_code/autonomous/idle_poller.rb +4 -8
- data/lib/rubyn_code/autonomous/task_claimer.rb +11 -11
- data/lib/rubyn_code/background/RUBYN.md +13 -0
- data/lib/rubyn_code/background/notifier.rb +0 -2
- data/lib/rubyn_code/background/worker.rb +60 -15
- data/lib/rubyn_code/cli/RUBYN.md +30 -0
- data/lib/rubyn_code/cli/app.rb +85 -9
- data/lib/rubyn_code/cli/commands/RUBYN.md +133 -0
- data/lib/rubyn_code/cli/commands/base.rb +53 -0
- data/lib/rubyn_code/cli/commands/budget.rb +24 -0
- data/lib/rubyn_code/cli/commands/clear.rb +16 -0
- data/lib/rubyn_code/cli/commands/compact.rb +21 -0
- data/lib/rubyn_code/cli/commands/context.rb +44 -0
- data/lib/rubyn_code/cli/commands/context_info.rb +56 -0
- data/lib/rubyn_code/cli/commands/cost.rb +23 -0
- data/lib/rubyn_code/cli/commands/diff.rb +30 -0
- data/lib/rubyn_code/cli/commands/doctor.rb +112 -0
- data/lib/rubyn_code/cli/commands/help.rb +41 -0
- data/lib/rubyn_code/cli/commands/model.rb +37 -0
- data/lib/rubyn_code/cli/commands/plan.rb +22 -0
- data/lib/rubyn_code/cli/commands/quit.rb +17 -0
- data/lib/rubyn_code/cli/commands/registry.rb +64 -0
- data/lib/rubyn_code/cli/commands/resume.rb +51 -0
- data/lib/rubyn_code/cli/commands/review.rb +26 -0
- data/lib/rubyn_code/cli/commands/skill.rb +32 -0
- data/lib/rubyn_code/cli/commands/spawn.rb +24 -0
- data/lib/rubyn_code/cli/commands/tasks.rb +32 -0
- data/lib/rubyn_code/cli/commands/tokens.rb +76 -0
- data/lib/rubyn_code/cli/commands/undo.rb +17 -0
- data/lib/rubyn_code/cli/commands/version.rb +16 -0
- data/lib/rubyn_code/cli/daemon_runner.rb +129 -0
- data/lib/rubyn_code/cli/input_handler.rb +20 -23
- data/lib/rubyn_code/cli/renderer.rb +25 -27
- data/lib/rubyn_code/cli/repl.rb +161 -194
- data/lib/rubyn_code/cli/setup.rb +117 -0
- data/lib/rubyn_code/cli/spinner.rb +40 -40
- data/lib/rubyn_code/cli/stream_formatter.rb +29 -28
- data/lib/rubyn_code/cli/version_check.rb +94 -0
- data/lib/rubyn_code/config/RUBYN.md +14 -0
- data/lib/rubyn_code/config/defaults.rb +28 -19
- data/lib/rubyn_code/config/project_config.rb +7 -9
- data/lib/rubyn_code/config/settings.rb +3 -3
- data/lib/rubyn_code/context/RUBYN.md +20 -0
- data/lib/rubyn_code/context/auto_compact.rb +7 -7
- data/lib/rubyn_code/context/compactor.rb +2 -2
- data/lib/rubyn_code/context/context_collapse.rb +45 -0
- data/lib/rubyn_code/context/manager.rb +20 -3
- data/lib/rubyn_code/context/manual_compact.rb +7 -7
- data/lib/rubyn_code/context/micro_compact.rb +12 -12
- data/lib/rubyn_code/db/RUBYN.md +40 -0
- data/lib/rubyn_code/db/connection.rb +13 -13
- data/lib/rubyn_code/db/migrator.rb +67 -27
- data/lib/rubyn_code/db/schema.rb +6 -6
- data/lib/rubyn_code/debug.rb +74 -0
- data/lib/rubyn_code/hooks/RUBYN.md +17 -0
- data/lib/rubyn_code/hooks/built_in.rb +9 -9
- data/lib/rubyn_code/hooks/registry.rb +5 -5
- data/lib/rubyn_code/hooks/runner.rb +1 -1
- data/lib/rubyn_code/hooks/user_hooks.rb +16 -16
- data/lib/rubyn_code/learning/RUBYN.md +16 -0
- data/lib/rubyn_code/learning/extractor.rb +22 -22
- data/lib/rubyn_code/learning/injector.rb +17 -18
- data/lib/rubyn_code/learning/instinct.rb +18 -14
- data/lib/rubyn_code/llm/RUBYN.md +15 -0
- data/lib/rubyn_code/llm/client.rb +121 -55
- data/lib/rubyn_code/llm/message_builder.rb +19 -15
- data/lib/rubyn_code/llm/streaming.rb +80 -50
- data/lib/rubyn_code/mcp/RUBYN.md +21 -0
- data/lib/rubyn_code/mcp/client.rb +25 -24
- data/lib/rubyn_code/mcp/config.rb +7 -7
- data/lib/rubyn_code/mcp/sse_transport.rb +27 -26
- data/lib/rubyn_code/mcp/stdio_transport.rb +22 -19
- data/lib/rubyn_code/mcp/tool_bridge.rb +32 -32
- data/lib/rubyn_code/memory/RUBYN.md +17 -0
- data/lib/rubyn_code/memory/models.rb +3 -3
- data/lib/rubyn_code/memory/search.rb +17 -17
- data/lib/rubyn_code/memory/session_persistence.rb +49 -34
- data/lib/rubyn_code/memory/store.rb +17 -17
- data/lib/rubyn_code/observability/RUBYN.md +19 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +16 -15
- data/lib/rubyn_code/observability/cost_calculator.rb +3 -3
- data/lib/rubyn_code/observability/token_counter.rb +1 -1
- data/lib/rubyn_code/observability/usage_reporter.rb +35 -35
- data/lib/rubyn_code/output/RUBYN.md +11 -0
- data/lib/rubyn_code/output/diff_renderer.rb +6 -6
- data/lib/rubyn_code/output/formatter.rb +4 -4
- data/lib/rubyn_code/permissions/RUBYN.md +17 -0
- data/lib/rubyn_code/permissions/prompter.rb +8 -8
- data/lib/rubyn_code/protocols/RUBYN.md +14 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +1 -1
- data/lib/rubyn_code/protocols/plan_approval.rb +9 -9
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +9 -11
- data/lib/rubyn_code/skills/RUBYN.md +19 -0
- data/lib/rubyn_code/skills/catalog.rb +7 -7
- data/lib/rubyn_code/skills/document.rb +15 -15
- data/lib/rubyn_code/skills/loader.rb +6 -8
- data/lib/rubyn_code/sub_agents/RUBYN.md +12 -0
- data/lib/rubyn_code/sub_agents/runner.rb +15 -15
- data/lib/rubyn_code/sub_agents/summarizer.rb +1 -1
- data/lib/rubyn_code/tasks/RUBYN.md +13 -0
- data/lib/rubyn_code/tasks/dag.rb +12 -16
- data/lib/rubyn_code/tasks/manager.rb +24 -24
- data/lib/rubyn_code/tasks/models.rb +4 -4
- data/lib/rubyn_code/teams/RUBYN.md +14 -0
- data/lib/rubyn_code/teams/mailbox.rb +38 -18
- data/lib/rubyn_code/teams/manager.rb +19 -19
- data/lib/rubyn_code/teams/teammate.rb +3 -4
- data/lib/rubyn_code/tools/RUBYN.md +38 -0
- data/lib/rubyn_code/tools/background_run.rb +9 -11
- data/lib/rubyn_code/tools/base.rb +54 -3
- data/lib/rubyn_code/tools/bash.rb +16 -34
- data/lib/rubyn_code/tools/bundle_add.rb +10 -12
- data/lib/rubyn_code/tools/bundle_install.rb +9 -11
- data/lib/rubyn_code/tools/compact.rb +10 -9
- data/lib/rubyn_code/tools/db_migrate.rb +17 -15
- data/lib/rubyn_code/tools/edit_file.rb +12 -12
- data/lib/rubyn_code/tools/executor.rb +9 -4
- data/lib/rubyn_code/tools/git_commit.rb +29 -34
- data/lib/rubyn_code/tools/git_diff.rb +17 -18
- data/lib/rubyn_code/tools/git_log.rb +17 -19
- data/lib/rubyn_code/tools/git_status.rb +18 -20
- data/lib/rubyn_code/tools/glob.rb +7 -9
- data/lib/rubyn_code/tools/grep.rb +11 -9
- data/lib/rubyn_code/tools/load_skill.rb +7 -7
- data/lib/rubyn_code/tools/memory_search.rb +13 -12
- data/lib/rubyn_code/tools/memory_write.rb +14 -12
- data/lib/rubyn_code/tools/rails_generate.rb +16 -16
- data/lib/rubyn_code/tools/read_file.rb +8 -7
- data/lib/rubyn_code/tools/read_inbox.rb +5 -5
- data/lib/rubyn_code/tools/registry.rb +2 -2
- data/lib/rubyn_code/tools/review_pr.rb +55 -55
- data/lib/rubyn_code/tools/run_specs.rb +20 -19
- data/lib/rubyn_code/tools/schema.rb +9 -11
- data/lib/rubyn_code/tools/send_message.rb +10 -10
- data/lib/rubyn_code/tools/spawn_agent.rb +51 -23
- data/lib/rubyn_code/tools/spawn_teammate.rb +21 -21
- data/lib/rubyn_code/tools/task.rb +28 -28
- data/lib/rubyn_code/tools/web_fetch.rb +46 -31
- data/lib/rubyn_code/tools/web_search.rb +64 -66
- data/lib/rubyn_code/tools/write_file.rb +7 -6
- data/lib/rubyn_code/version.rb +1 -1
- data/lib/rubyn_code.rb +136 -105
- metadata +94 -21
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
3
|
+
require '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(
|
|
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,
|
|
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
|
-
|
|
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[
|
|
57
|
-
metadata: parse_json_hash(row[
|
|
58
|
-
title: row[
|
|
59
|
-
model: row[
|
|
60
|
-
status: row[
|
|
61
|
-
project_path: row[
|
|
62
|
-
created_at: row[
|
|
63
|
-
updated_at: row[
|
|
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 <<
|
|
78
|
+
conditions << 'project_path = ?'
|
|
79
79
|
params << project_path
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
if status
|
|
83
|
-
conditions <<
|
|
83
|
+
conditions << 'status = ?'
|
|
84
84
|
params << status
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
-
where_clause = conditions.empty? ?
|
|
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[
|
|
101
|
-
project_path: row[
|
|
102
|
-
title: row[
|
|
103
|
-
model: row[
|
|
104
|
-
status: row[
|
|
105
|
-
metadata: parse_json_hash(row[
|
|
106
|
-
created_at: row[
|
|
107
|
-
updated_at: row[
|
|
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 <<
|
|
126
|
+
sets << 'title = ?'
|
|
127
127
|
params << value
|
|
128
128
|
when :status
|
|
129
|
-
sets <<
|
|
129
|
+
sets << 'status = ?'
|
|
130
130
|
params << value
|
|
131
131
|
when :model
|
|
132
|
-
sets <<
|
|
132
|
+
sets << 'model = ?'
|
|
133
133
|
params << value
|
|
134
134
|
when :metadata
|
|
135
|
-
sets <<
|
|
135
|
+
sets << 'metadata = ?'
|
|
136
136
|
params << JSON.generate(value)
|
|
137
137
|
when :messages
|
|
138
|
-
sets <<
|
|
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 <<
|
|
146
|
-
params << Time.now.utc.strftime(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
|
4
|
-
require
|
|
5
|
-
require_relative
|
|
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:
|
|
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(
|
|
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 <<
|
|
66
|
+
sets << 'content = ?'
|
|
67
67
|
params << value
|
|
68
68
|
when :tier
|
|
69
69
|
validate_tier!(value)
|
|
70
|
-
sets <<
|
|
70
|
+
sets << 'tier = ?'
|
|
71
71
|
params << value
|
|
72
72
|
when :category
|
|
73
73
|
validate_category!(value) if value
|
|
74
|
-
sets <<
|
|
74
|
+
sets << 'category = ?'
|
|
75
75
|
params << value
|
|
76
76
|
when :metadata
|
|
77
|
-
sets <<
|
|
77
|
+
sets << 'metadata = ?'
|
|
78
78
|
params << JSON.generate(value)
|
|
79
79
|
when :expires_at
|
|
80
|
-
sets <<
|
|
80
|
+
sets << 'expires_at = ?'
|
|
81
81
|
params << value
|
|
82
82
|
when :relevance_score
|
|
83
|
-
sets <<
|
|
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(
|
|
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(
|
|
111
|
+
now = Time.now.utc.strftime('%Y-%m-%d %H:%M:%S')
|
|
112
112
|
|
|
113
113
|
expired_ids = @db.query(
|
|
114
|
-
|
|
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[
|
|
116
|
+
).to_a.map { |row| row['id'] }
|
|
117
117
|
|
|
118
118
|
return 0 if expired_ids.empty?
|
|
119
119
|
|
|
120
|
-
placeholders = ([
|
|
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(
|
|
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
|
|
4
|
-
require
|
|
5
|
-
require_relative
|
|
6
|
-
require_relative
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
55
|
-
|
|
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: $#{
|
|
83
|
+
"Session budget exceeded: $#{'%.4f' % sc} >= $#{format('%.2f', @session_limit)} limit"
|
|
83
84
|
end
|
|
84
85
|
|
|
85
86
|
dc = daily_cost
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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(
|
|
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[
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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,
|
|
31
|
-
total_output = rows.sum { |r| fetch_int(r,
|
|
32
|
-
total_cost = rows.sum { |r| fetch_float(r,
|
|
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(
|
|
38
|
-
field(
|
|
39
|
-
field(
|
|
40
|
-
field(
|
|
41
|
-
field(
|
|
42
|
-
field(
|
|
43
|
-
field(
|
|
44
|
-
field(
|
|
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(
|
|
54
|
+
today = Time.now.utc.strftime('%Y-%m-%d')
|
|
55
55
|
rows = @db.query(
|
|
56
|
-
|
|
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
|
-
|
|
58
|
+
'WHERE created_at >= ? GROUP BY session_id',
|
|
59
59
|
["#{today}T00:00:00Z"]
|
|
60
60
|
).to_a
|
|
61
61
|
|
|
62
|
-
return
|
|
62
|
+
return 'No usage data for today.' if rows.empty?
|
|
63
63
|
|
|
64
|
-
total_input = rows.sum { |r| fetch_int(r,
|
|
65
|
-
total_output = rows.sum { |r| fetch_int(r,
|
|
66
|
-
total_cost = rows.sum { |r| fetch_float(r,
|
|
67
|
-
total_turns = rows.sum { |r| fetch_int(r,
|
|
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(
|
|
73
|
-
field(
|
|
74
|
-
field(
|
|
75
|
-
field(
|
|
76
|
-
field(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
96
|
+
lines = [header('Cost by Model')]
|
|
97
97
|
|
|
98
98
|
rows.each do |row|
|
|
99
|
-
model = row[
|
|
100
|
-
cost = fetch_float(row,
|
|
101
|
-
calls = fetch_int(row,
|
|
102
|
-
input_t = fetch_int(row,
|
|
103
|
-
output_t = fetch_int(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')
|
|
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(
|
|
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
|
-
|
|
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
|
|
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:
|
|
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(
|
|
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.
|
|
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
|
|
4
|
-
require
|
|
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:
|
|
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(
|
|
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.
|