rubyn-code 0.1.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 +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module RubynCode
|
|
7
|
+
module Memory
|
|
8
|
+
# Saves and restores full conversation sessions to SQLite, enabling
|
|
9
|
+
# session continuity across process restarts and session browsing.
|
|
10
|
+
class SessionPersistence
|
|
11
|
+
# @param db [DB::Connection] database connection
|
|
12
|
+
def initialize(db)
|
|
13
|
+
@db = db
|
|
14
|
+
ensure_table
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Persists a complete session snapshot.
|
|
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
|
|
25
|
+
# @return [void]
|
|
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")
|
|
28
|
+
messages_json = JSON.generate(messages)
|
|
29
|
+
meta_json = JSON.generate(metadata)
|
|
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])
|
|
32
|
+
INSERT INTO sessions (id, project_path, title, model, messages, status, metadata, created_at, updated_at)
|
|
33
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
34
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
35
|
+
messages = ?,
|
|
36
|
+
title = COALESCE(?, title),
|
|
37
|
+
model = COALESCE(?, model),
|
|
38
|
+
metadata = ?,
|
|
39
|
+
updated_at = ?
|
|
40
|
+
SQL
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Loads a session by ID.
|
|
44
|
+
#
|
|
45
|
+
# @param session_id [String]
|
|
46
|
+
# @return [Hash, nil] { messages:, metadata:, title:, model:, status:, project_path: } or nil
|
|
47
|
+
def load_session(session_id)
|
|
48
|
+
rows = @db.query(
|
|
49
|
+
"SELECT * FROM sessions WHERE id = ?",
|
|
50
|
+
[session_id]
|
|
51
|
+
).to_a
|
|
52
|
+
return nil if rows.empty?
|
|
53
|
+
|
|
54
|
+
row = rows.first
|
|
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"]
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Lists sessions, optionally filtered by project and/or status.
|
|
68
|
+
#
|
|
69
|
+
# @param project_path [String, nil] filter by project
|
|
70
|
+
# @param status [String, nil] filter by status ("active", "archived", "deleted")
|
|
71
|
+
# @param limit [Integer] maximum results (default 20)
|
|
72
|
+
# @return [Array<Hash>] session summaries (without full messages)
|
|
73
|
+
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 ')}"
|
|
88
|
+
params << limit
|
|
89
|
+
|
|
90
|
+
rows = @db.query(<<~SQL, params).to_a
|
|
91
|
+
SELECT id, project_path, title, model, status, metadata, created_at, updated_at
|
|
92
|
+
FROM sessions
|
|
93
|
+
#{where_clause}
|
|
94
|
+
ORDER BY updated_at DESC
|
|
95
|
+
LIMIT ?
|
|
96
|
+
SQL
|
|
97
|
+
|
|
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
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Updates session attributes.
|
|
113
|
+
#
|
|
114
|
+
# @param session_id [String]
|
|
115
|
+
# @param attrs [Hash] attributes to update (:title, :status, :model, :metadata, :messages)
|
|
116
|
+
# @return [void]
|
|
117
|
+
def update_session(session_id, **attrs)
|
|
118
|
+
return if attrs.empty?
|
|
119
|
+
|
|
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
|
+
|
|
143
|
+
return if sets.empty?
|
|
144
|
+
|
|
145
|
+
sets << "updated_at = ?"
|
|
146
|
+
params << Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
|
|
147
|
+
params << session_id
|
|
148
|
+
|
|
149
|
+
@db.execute("UPDATE sessions SET #{sets.join(', ')} WHERE id = ?", params)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Deletes a session permanently.
|
|
153
|
+
#
|
|
154
|
+
# @param session_id [String]
|
|
155
|
+
# @return [void]
|
|
156
|
+
def delete_session(session_id)
|
|
157
|
+
@db.execute("DELETE FROM sessions WHERE id = ?", [session_id])
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
private
|
|
161
|
+
|
|
162
|
+
def ensure_table
|
|
163
|
+
# Add messages column if it doesn't exist (migration schema didn't include it)
|
|
164
|
+
@db.execute("ALTER TABLE sessions ADD COLUMN messages TEXT NOT NULL DEFAULT '[]'")
|
|
165
|
+
rescue StandardError
|
|
166
|
+
# Column already exists or table doesn't exist yet — either way, safe to continue
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# @param raw [String, Array, nil]
|
|
170
|
+
# @return [Array]
|
|
171
|
+
def parse_json_array(raw)
|
|
172
|
+
case raw
|
|
173
|
+
when Array then raw
|
|
174
|
+
when String then JSON.parse(raw, symbolize_names: true)
|
|
175
|
+
else []
|
|
176
|
+
end
|
|
177
|
+
rescue JSON::ParserError
|
|
178
|
+
[]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @param raw [String, Hash, nil]
|
|
182
|
+
# @return [Hash]
|
|
183
|
+
def parse_json_hash(raw)
|
|
184
|
+
case raw
|
|
185
|
+
when Hash then raw
|
|
186
|
+
when String then JSON.parse(raw, symbolize_names: true)
|
|
187
|
+
else {}
|
|
188
|
+
end
|
|
189
|
+
rescue JSON::ParserError
|
|
190
|
+
{}
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "models"
|
|
6
|
+
|
|
7
|
+
module RubynCode
|
|
8
|
+
module Memory
|
|
9
|
+
# Writes and manages memories in SQLite, backed by an FTS5 full-text
|
|
10
|
+
# search index for fast retrieval. Handles expiration and relevance
|
|
11
|
+
# decay to keep the memory store manageable over time.
|
|
12
|
+
class Store
|
|
13
|
+
# @param db [DB::Connection] database connection
|
|
14
|
+
# @param project_path [String] scoping path for this memory store
|
|
15
|
+
def initialize(db, project_path:)
|
|
16
|
+
@db = db
|
|
17
|
+
@project_path = project_path
|
|
18
|
+
ensure_tables
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Persists a new memory and updates the FTS index.
|
|
22
|
+
#
|
|
23
|
+
# @param content [String] the memory content
|
|
24
|
+
# @param tier [String] retention tier ("short", "medium", "long")
|
|
25
|
+
# @param category [String, nil] classification category
|
|
26
|
+
# @param metadata [Hash] arbitrary metadata
|
|
27
|
+
# @param expires_at [String, nil] ISO 8601 expiration timestamp
|
|
28
|
+
# @return [MemoryRecord] the created record
|
|
29
|
+
def write(content:, tier: "medium", category: nil, metadata: {}, expires_at: nil)
|
|
30
|
+
validate_tier!(tier)
|
|
31
|
+
validate_category!(category) if category
|
|
32
|
+
|
|
33
|
+
id = SecureRandom.uuid
|
|
34
|
+
now = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
|
|
35
|
+
meta_json = JSON.generate(metadata)
|
|
36
|
+
|
|
37
|
+
@db.execute(<<~SQL, [id, @project_path, tier, category, content, 1.0, 0, now, expires_at, meta_json, now])
|
|
38
|
+
INSERT INTO memories (id, project_path, tier, category, content,
|
|
39
|
+
relevance_score, access_count, last_accessed_at,
|
|
40
|
+
expires_at, metadata, created_at)
|
|
41
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
42
|
+
SQL
|
|
43
|
+
|
|
44
|
+
MemoryRecord.new(
|
|
45
|
+
id: id, project_path: @project_path, tier: tier, category: category,
|
|
46
|
+
content: content, relevance_score: 1.0, access_count: 0,
|
|
47
|
+
last_accessed_at: now, expires_at: expires_at, metadata: metadata,
|
|
48
|
+
created_at: now
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Updates attributes on an existing memory.
|
|
53
|
+
#
|
|
54
|
+
# @param id [String] the memory ID
|
|
55
|
+
# @param attrs [Hash] attributes to update (content, tier, category, metadata, expires_at, relevance_score)
|
|
56
|
+
# @return [void]
|
|
57
|
+
def update(id, **attrs)
|
|
58
|
+
return if attrs.empty?
|
|
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
|
+
|
|
88
|
+
return if sets.empty?
|
|
89
|
+
|
|
90
|
+
params << id
|
|
91
|
+
@db.execute(
|
|
92
|
+
"UPDATE memories SET #{sets.join(', ')} WHERE id = ? AND project_path = '#{@project_path}'",
|
|
93
|
+
params
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# Content changes are picked up by LIKE-based search — no FTS sync needed
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Deletes a memory and its FTS index entry.
|
|
100
|
+
#
|
|
101
|
+
# @param id [String]
|
|
102
|
+
# @return [void]
|
|
103
|
+
def delete(id)
|
|
104
|
+
@db.execute("DELETE FROM memories WHERE id = ? AND project_path = ?", [id, @project_path])
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Removes all memories whose expires_at is in the past.
|
|
108
|
+
#
|
|
109
|
+
# @return [Integer] number of expired memories deleted
|
|
110
|
+
def expire_old!
|
|
111
|
+
now = Time.now.utc.strftime("%Y-%m-%d %H:%M:%S")
|
|
112
|
+
|
|
113
|
+
expired_ids = @db.query(
|
|
114
|
+
"SELECT id FROM memories WHERE project_path = ? AND expires_at IS NOT NULL AND expires_at < ?",
|
|
115
|
+
[@project_path, now]
|
|
116
|
+
).to_a.map { |row| row["id"] }
|
|
117
|
+
|
|
118
|
+
return 0 if expired_ids.empty?
|
|
119
|
+
|
|
120
|
+
placeholders = (["?"] * expired_ids.size).join(", ")
|
|
121
|
+
@db.execute(
|
|
122
|
+
"DELETE FROM memories WHERE id IN (#{placeholders}) AND project_path = ?",
|
|
123
|
+
expired_ids + [@project_path]
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
expired_ids.size
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Reduces the relevance_score of memories that have not been accessed
|
|
130
|
+
# recently, simulating natural memory decay.
|
|
131
|
+
#
|
|
132
|
+
# @param decay_rate [Float] amount to subtract from relevance_score (default 0.01)
|
|
133
|
+
# @return [void]
|
|
134
|
+
def decay!(decay_rate: 0.01)
|
|
135
|
+
cutoff = (Time.now.utc - 86_400).strftime("%Y-%m-%d %H:%M:%S") # 24 hours ago
|
|
136
|
+
|
|
137
|
+
@db.execute(<<~SQL, [decay_rate, @project_path, cutoff])
|
|
138
|
+
UPDATE memories
|
|
139
|
+
SET relevance_score = MAX(0.0, relevance_score - ?)
|
|
140
|
+
WHERE project_path = ?
|
|
141
|
+
AND last_accessed_at < ?
|
|
142
|
+
SQL
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def ensure_tables
|
|
148
|
+
@db.execute(<<~SQL)
|
|
149
|
+
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
|
|
161
|
+
)
|
|
162
|
+
SQL
|
|
163
|
+
|
|
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
|
|
177
|
+
SQL
|
|
178
|
+
|
|
179
|
+
# Search uses LIKE queries — no FTS table needed
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# @param tier [String]
|
|
183
|
+
# @raise [ArgumentError]
|
|
184
|
+
def validate_tier!(tier)
|
|
185
|
+
return if VALID_TIERS.include?(tier)
|
|
186
|
+
|
|
187
|
+
raise ArgumentError, "Invalid tier: #{tier.inspect}. Must be one of: #{VALID_TIERS.join(', ')}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# @param category [String]
|
|
191
|
+
# @raise [ArgumentError]
|
|
192
|
+
def validate_category!(category)
|
|
193
|
+
return if VALID_CATEGORIES.include?(category)
|
|
194
|
+
|
|
195
|
+
raise ArgumentError, "Invalid category: #{category.inspect}. Must be one of: #{VALID_CATEGORIES.join(', ')}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "models"
|
|
6
|
+
require_relative "cost_calculator"
|
|
7
|
+
|
|
8
|
+
module RubynCode
|
|
9
|
+
module Observability
|
|
10
|
+
# Tracks API spend and halts the agent when session or daily budgets are
|
|
11
|
+
# exceeded. Cost records are persisted to SQLite so budgets survive restarts.
|
|
12
|
+
class BudgetEnforcer
|
|
13
|
+
DEFAULT_SESSION_LIMIT = 5.00
|
|
14
|
+
DEFAULT_DAILY_LIMIT = 10.00
|
|
15
|
+
|
|
16
|
+
TABLE_NAME = "cost_records"
|
|
17
|
+
|
|
18
|
+
# @param db [DB::Connection] database connection
|
|
19
|
+
# @param session_id [String] current session identifier
|
|
20
|
+
# @param session_limit [Float] maximum USD spend per session
|
|
21
|
+
# @param daily_limit [Float] maximum USD spend per calendar day
|
|
22
|
+
def initialize(db, session_id:, session_limit: DEFAULT_SESSION_LIMIT, daily_limit: DEFAULT_DAILY_LIMIT)
|
|
23
|
+
@db = db
|
|
24
|
+
@session_id = session_id
|
|
25
|
+
@session_limit = session_limit.to_f
|
|
26
|
+
@daily_limit = daily_limit.to_f
|
|
27
|
+
|
|
28
|
+
ensure_table_exists
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Records a cost entry for an API call and persists it to the database.
|
|
32
|
+
#
|
|
33
|
+
# @param model [String] the model identifier
|
|
34
|
+
# @param input_tokens [Integer] input token count
|
|
35
|
+
# @param output_tokens [Integer] output token count
|
|
36
|
+
# @param cache_read_tokens [Integer] cache-read token count
|
|
37
|
+
# @param cache_write_tokens [Integer] cache-write token count
|
|
38
|
+
# @param request_type [String] the type of request (e.g., "chat", "compact")
|
|
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")
|
|
41
|
+
cost = CostCalculator.calculate(
|
|
42
|
+
model: model,
|
|
43
|
+
input_tokens: input_tokens,
|
|
44
|
+
output_tokens: output_tokens,
|
|
45
|
+
cache_read_tokens: cache_read_tokens,
|
|
46
|
+
cache_write_tokens: cache_write_tokens
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
id = SecureRandom.uuid
|
|
50
|
+
now = Time.now.utc.iso8601
|
|
51
|
+
|
|
52
|
+
@db.execute(
|
|
53
|
+
"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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
56
|
+
[id, @session_id, model, input_tokens, output_tokens,
|
|
57
|
+
cache_read_tokens, cache_write_tokens, cost, request_type, now]
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
CostRecord.new(
|
|
61
|
+
id: id,
|
|
62
|
+
session_id: @session_id,
|
|
63
|
+
model: model,
|
|
64
|
+
input_tokens: input_tokens,
|
|
65
|
+
output_tokens: output_tokens,
|
|
66
|
+
cache_read_tokens: cache_read_tokens,
|
|
67
|
+
cache_write_tokens: cache_write_tokens,
|
|
68
|
+
cost_usd: cost,
|
|
69
|
+
request_type: request_type,
|
|
70
|
+
created_at: now
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Raises BudgetExceededError if either the session or daily budget is exceeded.
|
|
75
|
+
#
|
|
76
|
+
# @raise [BudgetExceededError] when spend exceeds a limit
|
|
77
|
+
# @return [void]
|
|
78
|
+
def check!
|
|
79
|
+
sc = session_cost
|
|
80
|
+
if sc >= @session_limit
|
|
81
|
+
raise BudgetExceededError,
|
|
82
|
+
"Session budget exceeded: $#{"%.4f" % sc} >= $#{"%.2f" % @session_limit} limit"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
dc = daily_cost
|
|
86
|
+
if dc >= @daily_limit
|
|
87
|
+
raise BudgetExceededError,
|
|
88
|
+
"Daily budget exceeded: $#{"%.4f" % dc} >= $#{"%.2f" % @daily_limit} limit"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns the total cost accumulated in the current session.
|
|
93
|
+
#
|
|
94
|
+
# @return [Float] total session cost in USD
|
|
95
|
+
def session_cost
|
|
96
|
+
rows = @db.query(
|
|
97
|
+
"SELECT COALESCE(SUM(cost_usd), 0.0) AS total FROM #{TABLE_NAME} WHERE session_id = ?",
|
|
98
|
+
[@session_id]
|
|
99
|
+
).to_a
|
|
100
|
+
extract_total(rows)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Returns the total cost accumulated today (UTC).
|
|
104
|
+
#
|
|
105
|
+
# @return [Float] total daily cost in USD
|
|
106
|
+
def daily_cost
|
|
107
|
+
today = Time.now.utc.strftime("%Y-%m-%d")
|
|
108
|
+
rows = @db.query(
|
|
109
|
+
"SELECT COALESCE(SUM(cost_usd), 0.0) AS total FROM #{TABLE_NAME} WHERE created_at >= ?",
|
|
110
|
+
["#{today}T00:00:00Z"]
|
|
111
|
+
).to_a
|
|
112
|
+
extract_total(rows)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Returns the smaller of the session and daily remaining budgets.
|
|
116
|
+
#
|
|
117
|
+
# @return [Float] remaining budget in USD
|
|
118
|
+
def remaining_budget
|
|
119
|
+
session_remaining = @session_limit - session_cost
|
|
120
|
+
daily_remaining = @daily_limit - daily_cost
|
|
121
|
+
[session_remaining, daily_remaining].min
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def ensure_table_exists
|
|
127
|
+
@db.execute(<<~SQL)
|
|
128
|
+
CREATE TABLE IF NOT EXISTS #{TABLE_NAME} (
|
|
129
|
+
id TEXT PRIMARY KEY,
|
|
130
|
+
session_id TEXT NOT NULL,
|
|
131
|
+
model TEXT NOT NULL,
|
|
132
|
+
input_tokens INTEGER NOT NULL DEFAULT 0,
|
|
133
|
+
output_tokens INTEGER NOT NULL DEFAULT 0,
|
|
134
|
+
cache_read_tokens INTEGER NOT NULL DEFAULT 0,
|
|
135
|
+
cache_write_tokens INTEGER NOT NULL DEFAULT 0,
|
|
136
|
+
cost_usd REAL NOT NULL DEFAULT 0.0,
|
|
137
|
+
request_type TEXT NOT NULL DEFAULT 'chat',
|
|
138
|
+
created_at TEXT NOT NULL
|
|
139
|
+
)
|
|
140
|
+
SQL
|
|
141
|
+
|
|
142
|
+
@db.execute(<<~SQL)
|
|
143
|
+
CREATE INDEX IF NOT EXISTS idx_cost_records_session_id ON #{TABLE_NAME} (session_id)
|
|
144
|
+
SQL
|
|
145
|
+
|
|
146
|
+
@db.execute(<<~SQL)
|
|
147
|
+
CREATE INDEX IF NOT EXISTS idx_cost_records_created_at ON #{TABLE_NAME} (created_at)
|
|
148
|
+
SQL
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def extract_total(rows)
|
|
152
|
+
return 0.0 if rows.nil? || rows.empty?
|
|
153
|
+
|
|
154
|
+
row = rows.first
|
|
155
|
+
(row["total"] || row[:total] || 0.0).to_f
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Observability
|
|
5
|
+
# Maps model identifiers and token counts to USD cost.
|
|
6
|
+
#
|
|
7
|
+
# Pricing is based on per-million-token rates. Cache reads are billed at
|
|
8
|
+
# 10% of the input rate; cache writes at 25% of the input rate.
|
|
9
|
+
module CostCalculator
|
|
10
|
+
# Per-million-token rates: { model_prefix => [input_rate, output_rate] }
|
|
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]
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
CACHE_READ_DISCOUNT = 0.1
|
|
18
|
+
CACHE_WRITE_PREMIUM = 1.25
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Calculates the USD cost for a single API call.
|
|
22
|
+
#
|
|
23
|
+
# @param model [String] the model identifier (exact or prefix match)
|
|
24
|
+
# @param input_tokens [Integer] number of input tokens
|
|
25
|
+
# @param output_tokens [Integer] number of output tokens
|
|
26
|
+
# @param cache_read_tokens [Integer] tokens served from cache
|
|
27
|
+
# @param cache_write_tokens [Integer] tokens written to cache
|
|
28
|
+
# @return [Float] cost in USD
|
|
29
|
+
def calculate(model:, input_tokens:, output_tokens:, cache_read_tokens: 0, cache_write_tokens: 0)
|
|
30
|
+
input_rate, output_rate = rates_for(model)
|
|
31
|
+
|
|
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
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Resolves pricing rates for a model, falling back to prefix matching
|
|
43
|
+
# and then a conservative default.
|
|
44
|
+
#
|
|
45
|
+
# @param model [String]
|
|
46
|
+
# @return [Array(Float, Float)] [input_rate, output_rate]
|
|
47
|
+
def rates_for(model)
|
|
48
|
+
return PRICING[model] if PRICING.key?(model)
|
|
49
|
+
|
|
50
|
+
# Try prefix match (e.g., "claude-sonnet-4-20250514-v2" matches "claude-sonnet-4-20250514")
|
|
51
|
+
PRICING.each do |prefix, rates|
|
|
52
|
+
return rates if model.start_with?(prefix)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Conservative fallback: use the most expensive known model
|
|
56
|
+
PRICING.max_by { |_, rates| rates.first }.last
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubynCode
|
|
4
|
+
module Observability
|
|
5
|
+
# Immutable record of a single API call's cost, stored in the database.
|
|
6
|
+
CostRecord = Data.define(
|
|
7
|
+
:id,
|
|
8
|
+
:session_id,
|
|
9
|
+
:model,
|
|
10
|
+
:input_tokens,
|
|
11
|
+
:output_tokens,
|
|
12
|
+
:cache_read_tokens,
|
|
13
|
+
:cache_write_tokens,
|
|
14
|
+
:cost_usd,
|
|
15
|
+
:request_type,
|
|
16
|
+
:created_at
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# Snapshot of metrics for a single agent turn (request/response cycle).
|
|
20
|
+
TurnMetrics = Data.define(
|
|
21
|
+
:model,
|
|
22
|
+
:input_tokens,
|
|
23
|
+
:output_tokens,
|
|
24
|
+
:cost_usd,
|
|
25
|
+
:duration_ms,
|
|
26
|
+
:tool_calls_count
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module RubynCode
|
|
6
|
+
module Observability
|
|
7
|
+
# Estimates token counts from text using a character-based heuristic.
|
|
8
|
+
#
|
|
9
|
+
# This provides a fast approximation (~4 characters per token) suitable for
|
|
10
|
+
# budget tracking and context-window management. For exact counts, use the
|
|
11
|
+
# API's reported usage fields instead.
|
|
12
|
+
module TokenCounter
|
|
13
|
+
# Average characters per token for English text and source code.
|
|
14
|
+
CHARS_PER_TOKEN = 4
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Estimates the token count for a given string.
|
|
18
|
+
#
|
|
19
|
+
# @param text [String, nil] the text to estimate
|
|
20
|
+
# @return [Integer] estimated token count (minimum 0)
|
|
21
|
+
def estimate(text)
|
|
22
|
+
return 0 if text.nil? || text.empty?
|
|
23
|
+
|
|
24
|
+
(text.bytesize.to_f / CHARS_PER_TOKEN).ceil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Estimates the token count for an array of messages by serializing
|
|
28
|
+
# them to JSON first. Accounts for the structural overhead of message
|
|
29
|
+
# formatting (role tags, separators, etc.).
|
|
30
|
+
#
|
|
31
|
+
# @param messages [Array<Hash>] messages in the API conversation format
|
|
32
|
+
# @return [Integer] estimated token count (minimum 0)
|
|
33
|
+
def estimate_messages(messages)
|
|
34
|
+
return 0 if messages.nil? || messages.empty?
|
|
35
|
+
|
|
36
|
+
json = JSON.generate(messages)
|
|
37
|
+
estimate(json)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|