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.
Files changed (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. 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