anima-core 1.3.0 → 1.5.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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +23 -26
  3. data/README.md +118 -104
  4. data/agents/thoughts-analyzer.md +12 -7
  5. data/anima-core.gemspec +1 -0
  6. data/app/channels/session_channel.rb +38 -58
  7. data/app/decorators/agent_message_decorator.rb +7 -2
  8. data/app/decorators/message_decorator.rb +31 -100
  9. data/app/decorators/pending_from_melete_decorator.rb +36 -0
  10. data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
  11. data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
  12. data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
  13. data/app/decorators/pending_from_mneme_decorator.rb +44 -0
  14. data/app/decorators/pending_message_decorator.rb +94 -0
  15. data/app/decorators/pending_subagent_decorator.rb +46 -0
  16. data/app/decorators/pending_tool_response_decorator.rb +51 -0
  17. data/app/decorators/pending_user_message_decorator.rb +22 -0
  18. data/app/decorators/system_message_decorator.rb +5 -0
  19. data/app/decorators/tool_call_decorator.rb +16 -5
  20. data/app/decorators/tool_response_decorator.rb +2 -2
  21. data/app/decorators/user_message_decorator.rb +7 -2
  22. data/app/jobs/count_tokens_job.rb +23 -0
  23. data/app/jobs/drain_job.rb +169 -0
  24. data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
  25. data/app/jobs/melete_enrichment_job.rb +48 -0
  26. data/app/jobs/mneme_enrichment_job.rb +46 -0
  27. data/app/jobs/tool_execution_job.rb +87 -0
  28. data/app/models/concerns/token_estimation.rb +54 -0
  29. data/app/models/goal.rb +23 -11
  30. data/app/models/message.rb +46 -48
  31. data/app/models/pending_message.rb +407 -12
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +660 -566
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +157 -0
  36. data/bin/release +212 -0
  37. data/bin/with-llms +20 -0
  38. data/config/application.rb +1 -0
  39. data/config/database.yml +1 -0
  40. data/config/initializers/event_subscribers.rb +71 -4
  41. data/config/initializers/inflections.rb +3 -1
  42. data/db/cable_structure.sql +9 -0
  43. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  44. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  45. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  46. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  47. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  48. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  49. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  50. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  51. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  52. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  53. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  54. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  55. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  56. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  57. data/db/queue_structure.sql +61 -0
  58. data/db/structure.sql +133 -0
  59. data/lib/agents/registry.rb +1 -1
  60. data/lib/anima/cli.rb +41 -13
  61. data/lib/anima/installer.rb +13 -0
  62. data/lib/anima/settings.rb +16 -36
  63. data/lib/anima/version.rb +1 -1
  64. data/lib/events/authentication_required.rb +24 -0
  65. data/lib/events/bounce_back.rb +4 -4
  66. data/lib/events/eviction_completed.rb +28 -0
  67. data/lib/events/goal_created.rb +28 -0
  68. data/lib/events/goal_updated.rb +32 -0
  69. data/lib/events/llm_responded.rb +35 -0
  70. data/lib/events/message_created.rb +27 -0
  71. data/lib/events/message_updated.rb +25 -0
  72. data/lib/events/session_state_changed.rb +30 -0
  73. data/lib/events/skill_activated.rb +28 -0
  74. data/lib/events/start_melete.rb +36 -0
  75. data/lib/events/start_mneme.rb +33 -0
  76. data/lib/events/start_processing.rb +32 -0
  77. data/lib/events/subagent_evicted.rb +31 -0
  78. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  79. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  80. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  81. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  82. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  83. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  84. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  85. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  86. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  87. data/lib/events/subscribers/persister.rb +8 -9
  88. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  89. data/lib/events/subscribers/subagent_message_router.rb +28 -34
  90. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  91. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  92. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  93. data/lib/events/tool_executed.rb +34 -0
  94. data/lib/events/workflow_activated.rb +27 -0
  95. data/lib/llm/client.rb +46 -199
  96. data/lib/mcp/client_manager.rb +41 -46
  97. data/lib/mcp/stdio_transport.rb +9 -5
  98. data/lib/{analytical_brain → melete}/runner.rb +73 -68
  99. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
  100. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
  101. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  102. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
  103. data/lib/melete/tools/goal_messaging.rb +29 -0
  104. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
  105. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  106. data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
  107. data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
  108. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  109. data/lib/mneme/base_runner.rb +121 -0
  110. data/lib/mneme/l2_runner.rb +14 -20
  111. data/lib/mneme/recall_runner.rb +132 -0
  112. data/lib/mneme/runner.rb +123 -165
  113. data/lib/mneme/search.rb +104 -62
  114. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  115. data/lib/mneme/tools/save_snapshot.rb +2 -10
  116. data/lib/mneme/tools/surface_memory.rb +89 -0
  117. data/lib/mneme.rb +11 -5
  118. data/lib/providers/anthropic.rb +112 -7
  119. data/lib/shell_session.rb +290 -432
  120. data/lib/skills/definition.rb +2 -2
  121. data/lib/skills/registry.rb +1 -1
  122. data/lib/tools/base.rb +16 -1
  123. data/lib/tools/bash.rb +25 -55
  124. data/lib/tools/edit.rb +2 -0
  125. data/lib/tools/mark_goal_completed.rb +4 -5
  126. data/lib/tools/read.rb +2 -0
  127. data/lib/tools/registry.rb +85 -4
  128. data/lib/tools/response_truncator.rb +1 -1
  129. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  130. data/lib/tools/spawn_specialist.rb +22 -14
  131. data/lib/tools/spawn_subagent.rb +30 -20
  132. data/lib/tools/subagent_prompts.rb +17 -19
  133. data/lib/tools/think.rb +1 -1
  134. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  135. data/lib/tools/write.rb +2 -0
  136. data/lib/tui/app.rb +393 -149
  137. data/lib/tui/braille_spinner.rb +7 -7
  138. data/lib/tui/cable_client.rb +9 -16
  139. data/lib/tui/decorators/base_decorator.rb +47 -6
  140. data/lib/tui/decorators/bash_decorator.rb +1 -1
  141. data/lib/tui/decorators/edit_decorator.rb +4 -2
  142. data/lib/tui/decorators/read_decorator.rb +4 -2
  143. data/lib/tui/decorators/think_decorator.rb +2 -2
  144. data/lib/tui/decorators/web_get_decorator.rb +1 -1
  145. data/lib/tui/decorators/write_decorator.rb +4 -2
  146. data/lib/tui/flash.rb +19 -14
  147. data/lib/tui/formatting.rb +20 -9
  148. data/lib/tui/input_buffer.rb +6 -6
  149. data/lib/tui/message_store.rb +165 -28
  150. data/lib/tui/performance_logger.rb +2 -3
  151. data/lib/tui/screens/chat.rb +149 -79
  152. data/lib/tui/settings.rb +93 -0
  153. data/lib/workflows/definition.rb +3 -3
  154. data/lib/workflows/registry.rb +1 -1
  155. data/skills/github.md +38 -0
  156. data/templates/config.toml +16 -32
  157. data/templates/tui.toml +209 -0
  158. data/workflows/review_pr.md +18 -14
  159. metadata +98 -29
  160. data/app/jobs/agent_request_job.rb +0 -199
  161. data/app/jobs/analytical_brain_job.rb +0 -33
  162. data/app/jobs/count_message_tokens_job.rb +0 -39
  163. data/app/jobs/passive_recall_job.rb +0 -29
  164. data/app/models/concerns/message/broadcasting.rb +0 -85
  165. data/config/initializers/fts5_schema_dump.rb +0 -21
  166. data/lib/agent_loop.rb +0 -186
  167. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
  168. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
  169. data/lib/environment_probe.rb +0 -232
  170. data/lib/events/agent_message.rb +0 -11
  171. data/lib/events/subscribers/message_collector.rb +0 -64
  172. data/lib/events/tool_call.rb +0 -31
  173. data/lib/events/tool_response.rb +0 -33
  174. data/lib/mneme/compressed_viewport.rb +0 -200
  175. data/lib/mneme/passive_recall.rb +0 -69
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ module Tools
5
+ # Surfaces a past message into Aoide's next turn as a `from_mneme`
6
+ # phantom tool pair. Called by Mneme's recall loop when a search hit
7
+ # or a viewed message clears her relevance bar.
8
+ #
9
+ # The persisted {PendingMessage} carries the original +message_id+ in
10
+ # its +source_name+ (and through promotion ends up inside
11
+ # +tool_input.message_id+ of the phantom pair), so the same memory
12
+ # isn't re-surfaced on later cycles — Mneme::Search already excludes
13
+ # Aoide's viewport, and once a recall promotes it lives there.
14
+ #
15
+ # The muse explains +why+ she's surfacing this memory. The reason is
16
+ # logged but not shown to Aoide — keeping the surfaced content itself
17
+ # clean of meta-commentary.
18
+ class SurfaceMemory < ::Tools::Base
19
+ def self.tool_name = "surface_memory"
20
+
21
+ def self.description = "Surface a memory into Aoide's next turn. Use when a specific past message is genuinely useful for what she's working on now. Pass the message_id and a short reason — one sentence explaining why she needs this *now*."
22
+
23
+ def self.input_schema
24
+ {
25
+ type: "object",
26
+ properties: {
27
+ message_id: {type: "integer"},
28
+ why: {type: "string", description: "One-sentence justification — kept for logs, not shown to Aoide."}
29
+ },
30
+ required: %w[message_id why]
31
+ }
32
+ end
33
+
34
+ # @param main_session [Session] the session receiving the recall
35
+ def initialize(main_session:, **)
36
+ @main_session = main_session
37
+ end
38
+
39
+ def execute(input)
40
+ message_id = input["message_id"].to_i
41
+ why = input["why"].to_s.strip
42
+
43
+ message = Message.find_by(id: message_id)
44
+ return {error: "Message #{message_id} not found"} unless message
45
+ return {error: "Reason cannot be blank"} if why.empty?
46
+
47
+ content = render_snippet(message)
48
+
49
+ @main_session.pending_messages.create!(
50
+ content: content,
51
+ source_type: "recall",
52
+ source_name: message_id.to_s,
53
+ message_type: "from_mneme"
54
+ )
55
+
56
+ Mneme.logger.info("session=#{@main_session.id} — surfaced message #{message_id}: #{why}")
57
+
58
+ "Surfaced message #{message_id}."
59
+ end
60
+
61
+ private
62
+
63
+ # Formats the message as the text Aoide will read when the phantom
64
+ # pair promotes. Headed with origin metadata, bounded by the recall
65
+ # snippet-token budget so long messages don't blow out her viewport.
66
+ #
67
+ # @param message [Message]
68
+ # @return [String]
69
+ def render_snippet(message)
70
+ origin = message.session&.name.presence || "session ##{message.session_id}"
71
+ raw = extract_content(message)
72
+ max_chars = Anima::Settings.recall_max_snippet_tokens * TokenEstimation::BYTES_PER_TOKEN
73
+ "message #{message.id} (#{origin}): #{raw.truncate(max_chars)}"
74
+ end
75
+
76
+ def extract_content(message)
77
+ payload = message.payload
78
+ case message.message_type
79
+ when "user_message", "agent_message", "system_message"
80
+ payload["content"].to_s
81
+ when "tool_call"
82
+ payload.dig("tool_input", "thoughts").to_s
83
+ else
84
+ payload["content"].to_s
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
data/lib/mneme.rb CHANGED
@@ -1,12 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Mneme — the memory department. Watches for viewport eviction and creates
4
- # summaries before context is lost. Named after the Greek Titaness of memory.
3
+ # Mneme — the muse of memory. Watches for viewport eviction and creates
4
+ # summaries before context is lost. One of the Three Muses: she remembers
5
+ # while Melete prepares and Aoide performs.
5
6
  #
6
- # Mneme is the third event bus department alongside Nous (main agent) and
7
- # the Analytical Brain. It operates as a phantom LLM loop: observes the
8
- # main session, creates snapshots, but leaves no trace of its own reasoning.
7
+ # Operates as a phantom LLM loop: observes the main session, creates
8
+ # snapshots, but leaves no trace of her own reasoning.
9
9
  module Mneme
10
+ # Estimated token overhead for a synthetic +tool_use+/+tool_result+
11
+ # pair — the wrapper JSON that phantom promotions emit around their
12
+ # content (tool name, input hash, ids, framing). Added to the content's
13
+ # token estimate when sizing phantom pairs in the viewport.
14
+ TOOL_PAIR_OVERHEAD_TOKENS = 50
15
+
10
16
  # Dev-only logger that writes to log/mneme.log.
11
17
  # In non-development environments returns a null logger so
12
18
  # call sites don't need conditionals.
@@ -17,6 +17,34 @@ module Providers
17
17
  # subscription tokens on Sonnet/Opus. Without it, /v1/messages returns 400.
18
18
  OAUTH_PASSPHRASE = "You are Claude Code, Anthropic's official CLI for Claude."
19
19
 
20
+ # Rate limit header names for extraction
21
+ RATE_LIMIT_HEADERS = {
22
+ "5h_status" => "Anthropic-Ratelimit-Unified-5h-Status",
23
+ "5h_reset" => "Anthropic-Ratelimit-Unified-5h-Reset",
24
+ "5h_utilization" => "Anthropic-Ratelimit-Unified-5h-Utilization",
25
+ "7d_status" => "Anthropic-Ratelimit-Unified-7d-Status",
26
+ "7d_reset" => "Anthropic-Ratelimit-Unified-7d-Reset",
27
+ "7d_utilization" => "Anthropic-Ratelimit-Unified-7d-Utilization"
28
+ }.freeze
29
+
30
+ # Response wrapper containing both the parsed body and API metrics.
31
+ # Behaves like a Hash for backward compatibility (delegates to body).
32
+ #
33
+ # @!attribute [r] body
34
+ # @return [Hash] parsed API response
35
+ # @!attribute [r] api_metrics
36
+ # @return [Hash, nil] rate limits and usage data
37
+ ApiResponse = Data.define(:body, :api_metrics) do
38
+ # Delegate Hash methods to body for backward compatibility.
39
+ # Callers using response["content"] continue to work unchanged.
40
+ def [](key) = body[key]
41
+ def dig(...) = body.dig(...)
42
+ def fetch(...) = body.fetch(...)
43
+ def key?(key) = body.key?(key)
44
+ def to_h = body
45
+ def to_json(...) = body.to_json(...)
46
+ end
47
+
20
48
  class Error < StandardError; end
21
49
  class AuthenticationError < Error; end
22
50
  class TokenFormatError < Error; end
@@ -76,13 +104,17 @@ module Providers
76
104
  # @param model [String] Anthropic model identifier
77
105
  # @param messages [Array<Hash>] conversation messages
78
106
  # @param max_tokens [Integer] maximum tokens in the response
107
+ # @param include_metrics [Boolean] when true, returns an {ApiResponse}
108
+ # wrapper with both body and api_metrics; when false (default),
109
+ # returns just the parsed body Hash for backward compatibility
79
110
  # @param options [Hash] additional parameters (e.g. +system:+, +tools:+)
80
- # @return [Hash] parsed API response
111
+ # @return [Hash, ApiResponse] parsed API response, or wrapper with metrics
81
112
  # @raise [TransientError] on network failures or server errors (retryable)
82
113
  # @raise [AuthenticationError] on 401/403 (permanent)
83
114
  # @raise [Error] on other API errors
84
- def create_message(model:, messages:, max_tokens:, **options)
115
+ def create_message(model:, messages:, max_tokens:, include_metrics: false, **options)
85
116
  wrap_system_prompt!(options)
117
+ annotate_last_message_for_caching!(messages)
86
118
  body = {model: model, messages: messages, max_tokens: max_tokens}.merge(options)
87
119
 
88
120
  response = self.class.post(
@@ -92,7 +124,7 @@ module Providers
92
124
  timeout: Anima::Settings.api_timeout
93
125
  )
94
126
 
95
- handle_response(response)
127
+ handle_response(response, include_metrics: include_metrics)
96
128
  rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => network_error
97
129
  raise TransientError, "#{network_error.class}: #{network_error.message}"
98
130
  end
@@ -106,7 +138,6 @@ module Providers
106
138
  # @return [Integer] estimated input token count
107
139
  # @raise [Error] on API errors
108
140
  def count_tokens(model:, messages:, **options)
109
- wrap_system_prompt!(options)
110
141
  body = {model: model, messages: messages}.merge(options)
111
142
 
112
143
  response = self.class.post(
@@ -159,16 +190,56 @@ module Providers
159
190
  # Wraps the system parameter in the array-of-blocks format required by
160
191
  # Anthropic for OAuth tokens. The passphrase block is always present;
161
192
  # the caller's prompt (if any) is appended as the second block.
193
+ # The last block is annotated with +cache_control+ so the API caches
194
+ # the entire system prefix (tools are evaluated before system).
162
195
  #
163
196
  # @param options [Hash] mutable options hash (modified in place)
164
197
  # @return [void]
165
198
  def wrap_system_prompt!(options)
166
199
  prompt = options[:system]
167
200
  blocks = [{type: "text", text: OAUTH_PASSPHRASE}]
168
- blocks << {type: "text", text: prompt} if prompt
201
+ blocks << {type: "text", text: prompt, cache_control: {type: "ephemeral"}}
169
202
  options[:system] = blocks
170
203
  end
171
204
 
205
+ # Annotates the last message's last content block with +cache_control+
206
+ # so every subsequent API call in a tool-use loop hits the prefix cache.
207
+ # String content is normalized to array-of-blocks format since bare
208
+ # strings cannot carry +cache_control+ metadata.
209
+ #
210
+ # Clears stale breakpoints from earlier messages to stay within the
211
+ # Anthropic 4-breakpoint limit (tools + system consume 2).
212
+ #
213
+ # @param messages [Array<Hash>] mutable messages array (modified in place)
214
+ # @return [void]
215
+ def annotate_last_message_for_caching!(messages)
216
+ return if messages.empty?
217
+
218
+ clear_stale_cache_breakpoints!(messages[0...-1])
219
+
220
+ last_msg = messages.last
221
+ content = last_msg[:content]
222
+
223
+ case content
224
+ when String
225
+ last_msg[:content] = [{type: "text", text: content, cache_control: {type: "ephemeral"}}]
226
+ when Array
227
+ last_block = content.last
228
+ last_block[:cache_control] = {type: "ephemeral"} if last_block
229
+ end
230
+ end
231
+
232
+ # Removes +cache_control+ from content blocks in the given messages.
233
+ # Called before re-annotating the last message to stay within the
234
+ # Anthropic 4-breakpoint limit across tool-loop rounds.
235
+ def clear_stale_cache_breakpoints!(messages)
236
+ messages.each do |msg|
237
+ content = msg[:content]
238
+ next unless content.is_a?(Array)
239
+ content.each { |block| block.delete(:cache_control) if block.is_a?(Hash) }
240
+ end
241
+ end
242
+
172
243
  def request_headers
173
244
  {
174
245
  "Authorization" => "Bearer #{token}",
@@ -178,10 +249,13 @@ module Providers
178
249
  }
179
250
  end
180
251
 
181
- def handle_response(response)
252
+ def handle_response(response, include_metrics: false)
182
253
  case response.code
183
254
  when 200
184
- response.parsed_response
255
+ body = response.parsed_response
256
+ return body unless include_metrics
257
+
258
+ ApiResponse.new(body: body, api_metrics: extract_api_metrics(response))
185
259
  when 400
186
260
  raise Error, "Bad request: #{error_message(response)}"
187
261
  when 401
@@ -199,6 +273,37 @@ module Providers
199
273
  end
200
274
  end
201
275
 
276
+ # Extracts rate limit headers and usage data from an HTTParty response.
277
+ #
278
+ # @param response [HTTParty::Response] raw API response
279
+ # @return [Hash] with "rate_limits" and "usage" string keys
280
+ def extract_api_metrics(response)
281
+ {
282
+ "rate_limits" => extract_rate_limits(response.headers),
283
+ "usage" => response.parsed_response&.dig("usage")
284
+ }
285
+ end
286
+
287
+ # Extracts rate limit values from response headers.
288
+ #
289
+ # @param headers [Hash] HTTParty headers (case-insensitive)
290
+ # @return [Hash] normalized rate limit data
291
+ def extract_rate_limits(headers)
292
+ return {} unless headers
293
+
294
+ RATE_LIMIT_HEADERS.transform_values do |header_name|
295
+ # HTTParty headers are strings; VCR replays them as arrays
296
+ raw = headers[header_name]
297
+ value = raw.is_a?(Array) ? raw.first : raw
298
+ # Parse numeric values (utilization, reset timestamps)
299
+ case value
300
+ when /\A\d+\z/ then value.to_i
301
+ when /\A\d+\.\d+\z/ then value.to_f
302
+ else value
303
+ end
304
+ end
305
+ end
306
+
202
307
  def error_message(response)
203
308
  response.parsed_response&.dig("error", "message") || response.message
204
309
  rescue JSON::ParserError, NoMethodError