anima-core 1.0.2 → 1.1.1

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +51 -0
  4. data/README.md +63 -29
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +30 -11
  7. data/app/decorators/tool_call_decorator.rb +32 -3
  8. data/app/decorators/tool_decorator.rb +57 -0
  9. data/app/decorators/tool_response_decorator.rb +12 -4
  10. data/app/decorators/web_get_tool_decorator.rb +102 -0
  11. data/app/jobs/agent_request_job.rb +93 -23
  12. data/app/jobs/mneme_job.rb +51 -0
  13. data/app/jobs/passive_recall_job.rb +29 -0
  14. data/app/models/concerns/event/broadcasting.rb +4 -0
  15. data/app/models/event.rb +10 -0
  16. data/app/models/goal.rb +27 -0
  17. data/app/models/goal_pinned_event.rb +11 -0
  18. data/app/models/pinned_event.rb +41 -0
  19. data/app/models/session.rb +402 -6
  20. data/app/models/snapshot.rb +76 -0
  21. data/bin/jobs +5 -0
  22. data/config/initializers/event_subscribers.rb +12 -3
  23. data/config/initializers/fts5_schema_dump.rb +21 -0
  24. data/config/queue.yml +0 -1
  25. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  26. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  27. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  28. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  29. data/lib/agent_loop.rb +63 -20
  30. data/lib/analytical_brain/runner.rb +158 -65
  31. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  32. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  33. data/lib/anima/cli.rb +32 -9
  34. data/lib/anima/installer.rb +11 -24
  35. data/lib/anima/settings.rb +59 -0
  36. data/lib/anima/spinner.rb +75 -0
  37. data/lib/anima/version.rb +1 -1
  38. data/lib/environment_probe.rb +4 -4
  39. data/lib/events/bounce_back.rb +37 -0
  40. data/lib/events/subscribers/persister.rb +19 -0
  41. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  42. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  43. data/lib/events/tool_call.rb +5 -3
  44. data/lib/llm/client.rb +19 -9
  45. data/lib/mneme/compressed_viewport.rb +200 -0
  46. data/lib/mneme/l2_runner.rb +138 -0
  47. data/lib/mneme/passive_recall.rb +69 -0
  48. data/lib/mneme/runner.rb +254 -0
  49. data/lib/mneme/search.rb +150 -0
  50. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  51. data/lib/mneme/tools/everything_ok.rb +24 -0
  52. data/lib/mneme/tools/save_snapshot.rb +68 -0
  53. data/lib/mneme.rb +29 -0
  54. data/lib/providers/anthropic.rb +57 -13
  55. data/lib/shell_session.rb +194 -63
  56. data/lib/tasks/fts5.rake +6 -0
  57. data/lib/tools/base.rb +2 -1
  58. data/lib/tools/bash.rb +4 -2
  59. data/lib/tools/registry.rb +22 -3
  60. data/lib/tools/remember.rb +179 -0
  61. data/lib/tools/request_feature.rb +3 -1
  62. data/lib/tools/spawn_specialist.rb +21 -9
  63. data/lib/tools/spawn_subagent.rb +22 -11
  64. data/lib/tools/subagent_prompts.rb +20 -3
  65. data/lib/tools/web_get.rb +21 -10
  66. data/lib/tui/app.rb +222 -125
  67. data/lib/tui/decorators/base_decorator.rb +165 -0
  68. data/lib/tui/decorators/bash_decorator.rb +20 -0
  69. data/lib/tui/decorators/edit_decorator.rb +19 -0
  70. data/lib/tui/decorators/read_decorator.rb +24 -0
  71. data/lib/tui/decorators/think_decorator.rb +36 -0
  72. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  73. data/lib/tui/decorators/write_decorator.rb +19 -0
  74. data/lib/tui/flash.rb +139 -0
  75. data/lib/tui/formatting.rb +28 -0
  76. data/lib/tui/height_map.rb +93 -0
  77. data/lib/tui/message_store.rb +97 -8
  78. data/lib/tui/performance_logger.rb +90 -0
  79. data/lib/tui/screens/chat.rb +358 -133
  80. data/templates/config.toml +47 -0
  81. data/templates/soul.md +1 -1
  82. metadata +83 -4
  83. data/CHANGELOG.md +0 -80
  84. data/Gemfile +0 -17
  85. data/lib/tools/return_result.rb +0 -81
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ # Full-text search over event history using SQLite FTS5.
5
+ # Covers user messages, agent messages, and think events across all sessions.
6
+ #
7
+ # The interface is intentionally abstract — callers receive {Result} structs
8
+ # and never touch FTS5 directly. A future semantic search backend (embeddings,
9
+ # BM25 + re-ranking) can replace the implementation without changing callers.
10
+ #
11
+ # @example Search across all sessions
12
+ # results = Mneme::Search.query("authentication flow")
13
+ # results.each { |r| puts "event #{r.event_id}: #{r.snippet}" }
14
+ #
15
+ # @example Search within a single session
16
+ # results = Mneme::Search.query("OAuth config", session_id: 42)
17
+ class Search
18
+ # A single search result with enough context for display and drill-down.
19
+ #
20
+ # @!attribute event_id [Integer] the event's database ID
21
+ # @!attribute session_id [Integer] the session owning this event
22
+ # @!attribute snippet [String] highlighted excerpt from the matching content
23
+ # @!attribute rank [Float] FTS5 relevance score (lower = more relevant)
24
+ # @!attribute event_type [String] one of Event::TYPES
25
+ Result = Struct.new(:event_id, :session_id, :snippet, :rank, :event_type, keyword_init: true)
26
+
27
+ # Searches event history for the given terms.
28
+ #
29
+ # @param terms [String] search query (FTS5 syntax: words, phrases, OR/AND/NOT)
30
+ # @param session_id [Integer, nil] scope to a specific session (nil = all sessions)
31
+ # @param limit [Integer] maximum results
32
+ # @return [Array<Result>] ranked by relevance (best first)
33
+ def self.query(terms, session_id: nil, limit: Anima::Settings.recall_max_results)
34
+ new(terms, session_id: session_id, limit: limit).call
35
+ end
36
+
37
+ def initialize(terms, session_id: nil, limit: 5)
38
+ @terms = sanitize_query(terms)
39
+ @session_id = session_id
40
+ @limit = limit
41
+ end
42
+
43
+ # @return [Array<Result>] ranked by relevance (best first)
44
+ def call
45
+ return [] if @terms.blank?
46
+
47
+ rows = execute_fts_query
48
+ rows.map { |row| build_result(row) }
49
+ end
50
+
51
+ private
52
+
53
+ # Executes the FTS5 MATCH query with optional session scoping.
54
+ # Joins back to events table for session_id and event_type.
55
+ #
56
+ # @return [Array<Hash>] raw database rows
57
+ def execute_fts_query
58
+ if @session_id
59
+ connection.select_all(scoped_sql, "Mneme::Search", [@terms, @session_id, @limit]).to_a
60
+ else
61
+ connection.select_all(global_sql, "Mneme::Search", [@terms, @limit]).to_a
62
+ end
63
+ end
64
+
65
+ # FTS5 query across all sessions.
66
+ # Contentless FTS5 can't use snippet() — extract content from events directly.
67
+ def global_sql
68
+ <<~SQL
69
+ SELECT
70
+ e.id AS event_id,
71
+ e.session_id,
72
+ e.event_type,
73
+ CASE
74
+ WHEN e.event_type IN ('user_message', 'agent_message', 'system_message')
75
+ THEN substr(json_extract(e.payload, '$.content'), 1, 300)
76
+ WHEN e.event_type = 'tool_call'
77
+ THEN substr(json_extract(e.payload, '$.tool_input.thoughts'), 1, 300)
78
+ END AS snippet,
79
+ rank
80
+ FROM events_fts
81
+ JOIN events e ON e.id = events_fts.rowid
82
+ WHERE events_fts MATCH ?
83
+ ORDER BY rank
84
+ LIMIT ?
85
+ SQL
86
+ end
87
+
88
+ # FTS5 query scoped to a specific session.
89
+ def scoped_sql
90
+ <<~SQL
91
+ SELECT
92
+ e.id AS event_id,
93
+ e.session_id,
94
+ e.event_type,
95
+ CASE
96
+ WHEN e.event_type IN ('user_message', 'agent_message', 'system_message')
97
+ THEN substr(json_extract(e.payload, '$.content'), 1, 300)
98
+ WHEN e.event_type = 'tool_call'
99
+ THEN substr(json_extract(e.payload, '$.tool_input.thoughts'), 1, 300)
100
+ END AS snippet,
101
+ rank
102
+ FROM events_fts
103
+ JOIN events e ON e.id = events_fts.rowid
104
+ WHERE events_fts MATCH ?
105
+ AND e.session_id = ?
106
+ ORDER BY rank
107
+ LIMIT ?
108
+ SQL
109
+ end
110
+
111
+ # Builds a Result from a raw database row.
112
+ #
113
+ # @param row [Hash]
114
+ # @return [Result]
115
+ def build_result(row)
116
+ Result.new(
117
+ event_id: row["event_id"],
118
+ session_id: row["session_id"],
119
+ snippet: row["snippet"],
120
+ rank: row["rank"],
121
+ event_type: row["event_type"]
122
+ )
123
+ end
124
+
125
+ # Sanitizes user input for FTS5 MATCH safety.
126
+ # Strips special FTS5 operators that could cause syntax errors,
127
+ # keeps only alphanumeric words and quoted phrases.
128
+ #
129
+ # @param raw [String]
130
+ # @return [String] safe FTS5 query
131
+ def sanitize_query(raw)
132
+ return "" unless raw
133
+
134
+ # Extract quoted phrases and individual words, drop FTS5 operators
135
+ tokens = raw.scan(/"[^"]+?"|\S+/).reject { |token| token.match?(/\A[*:^{}()]+\z/) }
136
+ tokens.filter_map { |token| sanitize_token(token) }.join(" ")
137
+ end
138
+
139
+ def sanitize_token(token)
140
+ return token if token.start_with?('"')
141
+
142
+ cleaned = token.gsub(/[^a-zA-Z0-9-]/, "")
143
+ cleaned.empty? ? nil : cleaned
144
+ end
145
+
146
+ def connection
147
+ ActiveRecord::Base.connection
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ module Tools
5
+ # Pins critical events to active Goals so they survive viewport eviction.
6
+ # Mneme calls this when it sees important events (user instructions, key
7
+ # decisions, critical corrections) approaching the eviction zone.
8
+ #
9
+ # Events are pinned via a many-to-many join: one event can be attached
10
+ # to multiple Goals. When all referencing Goals complete, the pin is
11
+ # automatically released (reference-counted cleanup in {Goal#release_orphaned_pins!}).
12
+ class AttachEventsToGoals < ::Tools::Base
13
+ def self.tool_name = "attach_events_to_goals"
14
+
15
+ def self.description = "Pin critical events to active goals so they survive " \
16
+ "viewport eviction. Use this for events that are too important to lose — " \
17
+ "exact user instructions, key decisions, critical corrections. " \
18
+ "Events stay pinned until all attached goals complete."
19
+
20
+ def self.input_schema
21
+ {
22
+ type: "object",
23
+ properties: {
24
+ event_ids: {
25
+ type: "array",
26
+ items: {type: "integer"},
27
+ description: "Database IDs of events to pin (from `event N` prefixes in the viewport)"
28
+ },
29
+ goal_ids: {
30
+ type: "array",
31
+ items: {type: "integer"},
32
+ description: "IDs of active goals to attach the events to"
33
+ }
34
+ },
35
+ required: %w[event_ids goal_ids]
36
+ }
37
+ end
38
+
39
+ # @param main_session [Session] the session being observed
40
+ def initialize(main_session:, **)
41
+ @session = main_session
42
+ end
43
+
44
+ # @param input [Hash<String, Object>] with "event_ids" and "goal_ids"
45
+ # @return [String] confirmation with link count, or error description
46
+ def execute(input)
47
+ event_ids = Array(input["event_ids"]).map(&:to_i).uniq
48
+ goal_ids = Array(input["goal_ids"]).map(&:to_i).uniq
49
+
50
+ return "Error: event_ids cannot be empty" if event_ids.empty?
51
+ return "Error: goal_ids cannot be empty" if goal_ids.empty?
52
+
53
+ events = @session.events.where(id: event_ids)
54
+ goals = @session.goals.active.where(id: goal_ids)
55
+
56
+ missing_events = event_ids - events.pluck(:id)
57
+ inactive_goal_ids = goal_ids - goals.pluck(:id)
58
+
59
+ errors = []
60
+ errors << "Events not found: #{missing_events.join(", ")}" if missing_events.any?
61
+
62
+ if inactive_goal_ids.any?
63
+ completed_ids = @session.goals.completed.where(id: inactive_goal_ids).pluck(:id)
64
+ not_found_ids = inactive_goal_ids - completed_ids
65
+ errors << "Goals already completed: #{completed_ids.join(", ")}" if completed_ids.any?
66
+ errors << "Goals not found: #{not_found_ids.join(", ")}" if not_found_ids.any?
67
+ end
68
+
69
+ return "Error: #{errors.join("; ")}" if errors.any?
70
+
71
+ attached = attach(events, goals)
72
+ "Pinned #{attached} event-goal links"
73
+ end
74
+
75
+ private
76
+
77
+ def attach(events, goals)
78
+ events.sum do |event|
79
+ pinned = find_or_create_pinned_event(event)
80
+ link_to_goals(pinned, goals)
81
+ end
82
+ end
83
+
84
+ def link_to_goals(pinned, goals)
85
+ goals.each { |goal| GoalPinnedEvent.find_or_create_by!(goal: goal, pinned_event: pinned) }
86
+ goals.size
87
+ end
88
+
89
+ def find_or_create_pinned_event(event)
90
+ PinnedEvent.find_or_create_by!(event: event) do |pe|
91
+ pe.display_text = truncate_event_content(event)
92
+ end
93
+ end
94
+
95
+ def truncate_event_content(event)
96
+ content = event.payload&.dig("content").to_s.strip
97
+ content = "event #{event.id}" if content.empty?
98
+
99
+ if content.length > PinnedEvent::MAX_DISPLAY_TEXT_LENGTH
100
+ content[0, PinnedEvent::MAX_DISPLAY_TEXT_LENGTH - 1] + "…"
101
+ else
102
+ content
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ module Tools
5
+ # Sentinel tool signaling that Mneme has reviewed the viewport and
6
+ # determined no snapshot is needed. Called when the conversation
7
+ # context doesn't contain enough meaningful content to summarize.
8
+ class EverythingOk < ::Tools::Base
9
+ def self.tool_name = "everything_ok"
10
+
11
+ def self.description = "Signal that no snapshot is needed. " \
12
+ "Call this when the eviction zone contains only mechanical " \
13
+ "activity (tool calls) with no meaningful conversation to summarize."
14
+
15
+ def self.input_schema
16
+ {type: "object", properties: {}, required: []}
17
+ end
18
+
19
+ def execute(_input)
20
+ "Acknowledged. No snapshot needed."
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ module Tools
5
+ # Saves a summary snapshot of conversation context that is about to
6
+ # leave the viewport. The snapshot captures the "gist" of what happened
7
+ # so the agent retains awareness of past context.
8
+ #
9
+ # The text field has a max_tokens limit for predictable sizing — each
10
+ # snapshot is a fixed-size tile, enabling calculation of how many fit
11
+ # at each compression level.
12
+ class SaveSnapshot < ::Tools::Base
13
+ def self.tool_name = "save_snapshot"
14
+
15
+ def self.description = "Save a summary of the conversation context " \
16
+ "that is about to leave the viewport. Write a concise summary " \
17
+ "capturing key decisions, topics discussed, and important context. " \
18
+ "Focus on WHAT was decided and WHY, not mechanical details."
19
+
20
+ def self.input_schema
21
+ {
22
+ type: "object",
23
+ properties: {
24
+ text: {
25
+ type: "string",
26
+ description: "The summary text. Be concise but preserve key decisions, " \
27
+ "goals discussed, and important context. Max #{Anima::Settings.mneme_max_tokens} tokens."
28
+ }
29
+ },
30
+ required: %w[text]
31
+ }
32
+ end
33
+
34
+ # @param main_session [Session] the session being observed
35
+ # @param from_event_id [Integer] first event ID covered by this snapshot
36
+ # @param to_event_id [Integer] last event ID covered by this snapshot
37
+ # @param level [Integer] compression level (1 = from events, 2 = from L1 snapshots)
38
+ def initialize(main_session:, from_event_id:, to_event_id:, level: 1, **)
39
+ @main_session = main_session
40
+ @from_event_id = from_event_id
41
+ @to_event_id = to_event_id
42
+ @level = level
43
+ end
44
+
45
+ def execute(input)
46
+ text = input["text"].to_s.strip
47
+ return "Error: Summary text cannot be blank" if text.empty?
48
+
49
+ snapshot = @main_session.snapshots.create!(
50
+ text: text,
51
+ from_event_id: @from_event_id,
52
+ to_event_id: @to_event_id,
53
+ level: @level,
54
+ token_count: estimate_tokens(text)
55
+ )
56
+
57
+ "Snapshot saved (id: #{snapshot.id}, events #{@from_event_id}..#{@to_event_id})"
58
+ end
59
+
60
+ private
61
+
62
+ # @return [Integer] estimated token count for the summary text
63
+ def estimate_tokens(text)
64
+ [(text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
65
+ end
66
+ end
67
+ end
68
+ end
data/lib/mneme.rb ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
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.
5
+ #
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.
9
+ module Mneme
10
+ # Dev-only logger that writes to log/mneme.log.
11
+ # In non-development environments returns a null logger so
12
+ # call sites don't need conditionals.
13
+ #
14
+ # @return [Logger]
15
+ def self.logger
16
+ @logger ||= build_logger
17
+ end
18
+
19
+ def self.build_logger
20
+ return Logger.new(File::NULL) unless Rails.env.development?
21
+
22
+ Logger.new(Rails.root.join("log", "mneme.log")).tap do |log|
23
+ log.formatter = proc { |severity, time, _progname, msg|
24
+ "[#{time.strftime("%H:%M:%S.%L")}] #{severity} #{msg}\n"
25
+ }
26
+ end
27
+ end
28
+ private_class_method :build_logger
29
+ end
@@ -13,6 +13,10 @@ module Providers
13
13
  API_VERSION = "2023-06-01"
14
14
  REQUIRED_BETA = "oauth-2025-04-20"
15
15
 
16
+ # Anthropic requires this exact string as the first system block for OAuth
17
+ # subscription tokens on Sonnet/Opus. Without it, /v1/messages returns 400.
18
+ OAUTH_PASSPHRASE = "You are Claude Code, Anthropic's official CLI for Claude."
19
+
16
20
  class Error < StandardError; end
17
21
  class AuthenticationError < Error; end
18
22
  class TokenFormatError < Error; end
@@ -25,11 +29,13 @@ module Providers
25
29
  class << self
26
30
  def fetch_token
27
31
  token = CredentialStore.read("anthropic", "subscription_token")
28
- raise AuthenticationError, <<~MSG.strip if token.blank?
32
+ return token if token.present?
33
+ return "sk-ant-oat01-#{"0" * 68}" if ENV["CI"]
34
+
35
+ raise AuthenticationError, <<~MSG.strip
29
36
  No Anthropic subscription token found in credentials.
30
37
  Use the TUI token setup (Ctrl+a → a) to configure your token.
31
38
  MSG
32
- token
33
39
  end
34
40
 
35
41
  def validate_token_format!(token)
@@ -46,6 +52,13 @@ module Providers
46
52
  true
47
53
  end
48
54
 
55
+ # Validate a token against the live Anthropic API.
56
+ # Delegates to {#validate_credentials!} on a throwaway instance.
57
+ #
58
+ # @param token [String] Anthropic API token to validate
59
+ # @return [true] when the API accepts the token
60
+ # @raise [TransientError] on network failures or server errors (retryable)
61
+ # @raise [AuthenticationError] on 401/403 (permanent)
49
62
  def validate_token_api!(token)
50
63
  provider = new(token)
51
64
  provider.validate_credentials!
@@ -58,7 +71,18 @@ module Providers
58
71
  @token = token || self.class.fetch_token
59
72
  end
60
73
 
74
+ # Send a message to the Anthropic API and return the parsed response.
75
+ #
76
+ # @param model [String] Anthropic model identifier
77
+ # @param messages [Array<Hash>] conversation messages
78
+ # @param max_tokens [Integer] maximum tokens in the response
79
+ # @param options [Hash] additional parameters (e.g. +system:+, +tools:+)
80
+ # @return [Hash] parsed API response
81
+ # @raise [TransientError] on network failures or server errors (retryable)
82
+ # @raise [AuthenticationError] on 401/403 (permanent)
83
+ # @raise [Error] on other API errors
61
84
  def create_message(model:, messages:, max_tokens:, **options)
85
+ wrap_system_prompt!(options)
62
86
  body = {model: model, messages: messages, max_tokens: max_tokens}.merge(options)
63
87
 
64
88
  response = self.class.post(
@@ -69,8 +93,8 @@ module Providers
69
93
  )
70
94
 
71
95
  handle_response(response)
72
- rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => e
73
- raise TransientError, "#{e.class}: #{e.message}"
96
+ rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => network_error
97
+ raise TransientError, "#{network_error.class}: #{network_error.message}"
74
98
  end
75
99
 
76
100
  # Count tokens in a message payload without creating a message.
@@ -82,6 +106,7 @@ module Providers
82
106
  # @return [Integer] estimated input token count
83
107
  # @raise [Error] on API errors
84
108
  def count_tokens(model:, messages:, **options)
109
+ wrap_system_prompt!(options)
85
110
  body = {model: model, messages: messages}.merge(options)
86
111
 
87
112
  response = self.class.post(
@@ -93,18 +118,22 @@ module Providers
93
118
 
94
119
  result = handle_response(response)
95
120
  result["input_tokens"]
96
- rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => e
97
- raise TransientError, "#{e.class}: #{e.message}"
121
+ rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => network_error
122
+ raise TransientError, "#{network_error.class}: #{network_error.message}"
98
123
  end
99
124
 
125
+ # Verify the token is accepted by Anthropic using the free models endpoint.
126
+ # Returns +true+ on success; raises typed exceptions on failure so callers
127
+ # can distinguish permanent auth problems from transient outages.
128
+ #
129
+ # @return [true] when the API accepts the token
130
+ # @raise [AuthenticationError] on 401 (invalid token) or 403 (restricted credential)
131
+ # @raise [RateLimitError] on 429
132
+ # @raise [ServerError] on 5xx
133
+ # @raise [TransientError] on network-level failures
100
134
  def validate_credentials!
101
- response = self.class.post(
102
- "/v1/messages",
103
- body: {
104
- model: Anima::Settings.model,
105
- messages: [{role: "user", content: "Hi"}],
106
- max_tokens: 1
107
- }.to_json,
135
+ response = self.class.get(
136
+ "/v1/models",
108
137
  headers: request_headers,
109
138
  timeout: Anima::Settings.api_timeout
110
139
  )
@@ -121,10 +150,25 @@ module Providers
121
150
  else
122
151
  handle_response(response)
123
152
  end
153
+ rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => network_error
154
+ raise TransientError, "#{network_error.class}: #{network_error.message}"
124
155
  end
125
156
 
126
157
  private
127
158
 
159
+ # Wraps the system parameter in the array-of-blocks format required by
160
+ # Anthropic for OAuth tokens. The passphrase block is always present;
161
+ # the caller's prompt (if any) is appended as the second block.
162
+ #
163
+ # @param options [Hash] mutable options hash (modified in place)
164
+ # @return [void]
165
+ def wrap_system_prompt!(options)
166
+ prompt = options[:system]
167
+ blocks = [{type: "text", text: OAUTH_PASSPHRASE}]
168
+ blocks << {type: "text", text: prompt} if prompt
169
+ options[:system] = blocks
170
+ end
171
+
128
172
  def request_headers
129
173
  {
130
174
  "Authorization" => "Bearer #{token}",