anima-core 0.1.0 → 0.2.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -3
  3. data/Gemfile +17 -0
  4. data/Procfile +2 -0
  5. data/Procfile.dev +2 -0
  6. data/README.md +68 -26
  7. data/Rakefile +19 -7
  8. data/anima-core.gemspec +41 -0
  9. data/app/channels/application_cable/channel.rb +6 -0
  10. data/app/channels/application_cable/connection.rb +6 -0
  11. data/app/channels/session_channel.rb +218 -0
  12. data/app/controllers/api/sessions_controller.rb +25 -0
  13. data/app/controllers/application_controller.rb +4 -0
  14. data/app/decorators/agent_message_decorator.rb +24 -0
  15. data/app/decorators/application_decorator.rb +6 -0
  16. data/app/decorators/event_decorator.rb +173 -0
  17. data/app/decorators/system_message_decorator.rb +21 -0
  18. data/app/decorators/tool_call_decorator.rb +48 -0
  19. data/app/decorators/tool_response_decorator.rb +37 -0
  20. data/app/decorators/user_message_decorator.rb +24 -0
  21. data/app/jobs/agent_request_job.rb +59 -0
  22. data/app/jobs/count_event_tokens_job.rb +1 -1
  23. data/app/models/event.rb +17 -0
  24. data/app/models/session.rb +40 -19
  25. data/bin/jobs +6 -0
  26. data/bin/rails +6 -0
  27. data/bin/rake +6 -0
  28. data/config/application.rb +5 -0
  29. data/config/cable.yml +14 -0
  30. data/config/database.yml +12 -0
  31. data/config/initializers/event_subscribers.rb +11 -0
  32. data/config/puma.rb +13 -0
  33. data/config/routes.rb +8 -0
  34. data/config.ru +5 -0
  35. data/db/cable_schema.rb +23 -0
  36. data/db/migrate/20260312170000_add_view_mode_to_sessions.rb +7 -0
  37. data/lib/agent_loop.rb +97 -0
  38. data/lib/anima/cli.rb +64 -9
  39. data/lib/anima/installer.rb +4 -3
  40. data/lib/anima/version.rb +1 -1
  41. data/lib/anima.rb +1 -0
  42. data/lib/events/subscribers/action_cable_bridge.rb +59 -0
  43. data/lib/events/subscribers/persister.rb +14 -4
  44. data/lib/providers/anthropic.rb +11 -2
  45. data/lib/tui/app.rb +209 -45
  46. data/lib/tui/cable_client.rb +387 -0
  47. data/lib/tui/input_buffer.rb +181 -0
  48. data/lib/tui/message_store.rb +122 -0
  49. data/lib/tui/screens/chat.rb +567 -88
  50. metadata +103 -5
  51. data/lib/tui/screens/anthropic.rb +0 -25
  52. data/lib/tui/screens/settings.rb +0 -52
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base decorator for {Event} records, providing multi-resolution rendering
4
+ # for the TUI. Each event type has a dedicated subclass that implements
5
+ # rendering methods for each view mode (basic, verbose, debug).
6
+ #
7
+ # Decorators return structured hashes (not pre-formatted strings) so that
8
+ # the TUI can style and lay out content based on semantic role, without
9
+ # fragile regex parsing. The TUI receives structured data via ActionCable
10
+ # and formats it for display.
11
+ #
12
+ # Subclasses must override {#render_basic}. Verbose and debug modes
13
+ # delegate to basic until subclasses provide their own implementations.
14
+ #
15
+ # @example Decorate an Event AR model
16
+ # decorator = EventDecorator.for(event)
17
+ # decorator.render_basic #=> {role: :user, content: "hello"} or nil
18
+ #
19
+ # @example Render for a specific view mode
20
+ # decorator = EventDecorator.for(event)
21
+ # decorator.render("verbose") #=> {role: :user, content: "hello", timestamp: 1709312325000000000}
22
+ #
23
+ # @example Decorate a raw payload hash (from EventBus)
24
+ # decorator = EventDecorator.for(type: "user_message", content: "hello")
25
+ # decorator.render_basic #=> {role: :user, content: "hello"}
26
+ class EventDecorator < ApplicationDecorator
27
+ delegate_all
28
+
29
+ TOOL_ICON = "\u{1F527}"
30
+ RETURN_ARROW = "\u21A9"
31
+ ERROR_ICON = "\u274C"
32
+
33
+ DECORATOR_MAP = {
34
+ "user_message" => "UserMessageDecorator",
35
+ "agent_message" => "AgentMessageDecorator",
36
+ "tool_call" => "ToolCallDecorator",
37
+ "tool_response" => "ToolResponseDecorator",
38
+ "system_message" => "SystemMessageDecorator"
39
+ }.freeze
40
+ private_constant :DECORATOR_MAP
41
+
42
+ # Normalizes hash payloads into an Event-like interface so decorators
43
+ # can use {#payload}, {#event_type}, etc. uniformly on both AR models
44
+ # and raw EventBus hashes.
45
+ #
46
+ # @!attribute event_type [r] the event's type (e.g. "user_message")
47
+ # @!attribute payload [r] string-keyed hash of event data
48
+ # @!attribute timestamp [r] nanosecond-precision timestamp
49
+ # @!attribute token_count [r] cumulative token count
50
+ EventPayload = Struct.new(:event_type, :payload, :timestamp, :token_count, keyword_init: true) do
51
+ # Heuristic token estimate matching {Event#estimate_tokens} so decorators
52
+ # can call it uniformly on both AR models and hash payloads.
53
+ # @return [Integer] at least 1
54
+ def estimate_tokens
55
+ text = if event_type.to_s.in?(%w[tool_call tool_response])
56
+ payload.to_json
57
+ else
58
+ payload&.dig("content").to_s
59
+ end
60
+ [(text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
61
+ end
62
+ end
63
+
64
+ # Factory returning the appropriate subclass decorator for the given event.
65
+ # Hashes are normalized via {EventPayload} to provide a uniform interface.
66
+ #
67
+ # @param event [Event, Hash] an Event AR model or a raw payload hash
68
+ # @return [EventDecorator, nil] decorated event, or nil for unknown types
69
+ def self.for(event)
70
+ source = wrap_source(event)
71
+ klass_name = DECORATOR_MAP[source.event_type]
72
+ return nil unless klass_name
73
+
74
+ klass_name.constantize.new(source)
75
+ end
76
+
77
+ RENDER_DISPATCH = {
78
+ "basic" => :render_basic,
79
+ "verbose" => :render_verbose,
80
+ "debug" => :render_debug
81
+ }.freeze
82
+ private_constant :RENDER_DISPATCH
83
+
84
+ # Dispatches to the render method for the given view mode.
85
+ #
86
+ # @param mode [String] one of "basic", "verbose", "debug"
87
+ # @return [Hash, nil] structured event data, or nil to hide the event
88
+ # @raise [ArgumentError] if the mode is not a valid view mode
89
+ def render(mode)
90
+ method = RENDER_DISPATCH[mode]
91
+ raise ArgumentError, "Invalid view mode: #{mode.inspect}" unless method
92
+
93
+ public_send(method)
94
+ end
95
+
96
+ # @abstract Subclasses must implement to render the event for basic view mode.
97
+ # @return [Hash, nil] structured event data, or nil to hide the event
98
+ def render_basic
99
+ raise NotImplementedError, "#{self.class} must implement #render_basic"
100
+ end
101
+
102
+ # Verbose view mode with timestamps and tool details.
103
+ # Delegates to {#render_basic} until subclasses provide their own implementations.
104
+ # @return [Hash, nil] structured event data, or nil to hide the event
105
+ def render_verbose
106
+ render_basic
107
+ end
108
+
109
+ # Debug view mode with token counts and system prompts.
110
+ # Delegates to {#render_basic} until subclasses provide their own implementations.
111
+ # @return [Hash, nil] structured event data, or nil to hide the event
112
+ def render_debug
113
+ render_basic
114
+ end
115
+
116
+ private
117
+
118
+ # Token count for display: exact count from {CountEventTokensJob} when
119
+ # available, heuristic estimate otherwise. Estimated counts are flagged
120
+ # so the TUI can prefix them with a tilde.
121
+ #
122
+ # @return [Hash] `{tokens: Integer, estimated: Boolean}`
123
+ def token_info
124
+ count = token_count.to_i
125
+ if count > 0
126
+ {tokens: count, estimated: false}
127
+ else
128
+ {tokens: estimate_token_count, estimated: true}
129
+ end
130
+ end
131
+
132
+ # Delegates to the underlying object's heuristic token estimator.
133
+ # Both {Event} AR models and {EventPayload} structs implement this.
134
+ #
135
+ # @return [Integer] at least 1
136
+ def estimate_token_count
137
+ object.estimate_tokens
138
+ end
139
+
140
+ # Extracts display content from the event payload.
141
+ # @return [String, nil]
142
+ def content
143
+ payload["content"]
144
+ end
145
+
146
+ # Truncates multi-line text, appending "..." when lines exceed the limit.
147
+ # @param text [String, nil] text to truncate (nil is coerced to empty string)
148
+ # @param max_lines [Integer] maximum number of lines to keep
149
+ # @return [String] truncated text
150
+ def truncate_lines(text, max_lines:)
151
+ str = text.to_s
152
+ lines = str.split("\n")
153
+ return str unless lines.size > max_lines
154
+
155
+ lines.first(max_lines).push("...").join("\n")
156
+ end
157
+
158
+ # Normalizes input to something Draper can wrap.
159
+ # Event AR models pass through; hashes become EventPayload structs
160
+ # with string-normalized keys.
161
+ def self.wrap_source(event)
162
+ return event unless event.is_a?(Hash)
163
+
164
+ normalized = event.transform_keys(&:to_s)
165
+ EventPayload.new(
166
+ event_type: normalized["type"].to_s,
167
+ payload: normalized,
168
+ timestamp: normalized["timestamp"],
169
+ token_count: normalized["token_count"]&.to_i || 0
170
+ )
171
+ end
172
+ private_class_method :wrap_source
173
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates system_message events for display in the TUI.
4
+ # Hidden in basic mode. Verbose and debug modes return timestamped system info.
5
+ class SystemMessageDecorator < EventDecorator
6
+ # @return [nil] system messages are hidden in basic mode
7
+ def render_basic
8
+ nil
9
+ end
10
+
11
+ # @return [Hash] structured system message data
12
+ # `{role: :system, content: String, timestamp: Integer|nil}`
13
+ def render_verbose
14
+ {role: :system, content: content, timestamp: timestamp}
15
+ end
16
+
17
+ # @return [Hash] same as verbose — system messages have no additional debug data
18
+ def render_debug
19
+ render_verbose
20
+ end
21
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates tool_call events for display in the TUI.
4
+ # Hidden in basic mode — tool activity is represented by the
5
+ # aggregated tool counter instead. Verbose mode returns tool name
6
+ # and a formatted preview of the input arguments. Debug mode shows
7
+ # full untruncated input as pretty-printed JSON with tool_use_id.
8
+ class ToolCallDecorator < EventDecorator
9
+ # @return [nil] tool calls are hidden in basic mode
10
+ def render_basic
11
+ nil
12
+ end
13
+
14
+ # @return [Hash] structured tool call data
15
+ # `{role: :tool_call, tool: String, input: String, timestamp: Integer|nil}`
16
+ def render_verbose
17
+ {role: :tool_call, tool: payload["tool_name"], input: format_input, timestamp: timestamp}
18
+ end
19
+
20
+ # @return [Hash] full tool call data with untruncated input and tool_use_id
21
+ # `{role: :tool_call, tool: String, input: String, tool_use_id: String|nil, timestamp: Integer|nil}`
22
+ def render_debug
23
+ {
24
+ role: :tool_call,
25
+ tool: payload["tool_name"],
26
+ input: JSON.pretty_generate(payload["tool_input"] || {}),
27
+ tool_use_id: payload["tool_use_id"],
28
+ timestamp: timestamp
29
+ }
30
+ end
31
+
32
+ private
33
+
34
+ # Formats tool input for display, with tool-specific formatting for
35
+ # known tools and generic JSON fallback for others.
36
+ # @return [String] formatted input preview
37
+ def format_input
38
+ input = payload["tool_input"]
39
+ case payload["tool_name"]
40
+ when "bash"
41
+ "$ #{input&.dig("command")}"
42
+ when "web_get"
43
+ "GET #{input&.dig("url")}"
44
+ else
45
+ truncate_lines(input.to_json, max_lines: 2)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates tool_response events for display in the TUI.
4
+ # Hidden in basic mode — tool activity is represented by the
5
+ # aggregated tool counter instead. Verbose mode returns truncated
6
+ # output with a success/failure indicator. Debug mode shows full
7
+ # untruncated output with tool_use_id and estimated token count.
8
+ class ToolResponseDecorator < EventDecorator
9
+ # @return [nil] tool responses are hidden in basic mode
10
+ def render_basic
11
+ nil
12
+ end
13
+
14
+ # @return [Hash] structured tool response data
15
+ # `{role: :tool_response, content: String, success: Boolean, timestamp: Integer|nil}`
16
+ def render_verbose
17
+ {
18
+ role: :tool_response,
19
+ content: truncate_lines(content, max_lines: 3),
20
+ success: payload["success"] != false,
21
+ timestamp: timestamp
22
+ }
23
+ end
24
+
25
+ # @return [Hash] full tool response data with untruncated content, tool_use_id, and token estimate
26
+ # `{role: :tool_response, content: String, success: Boolean, tool_use_id: String|nil,
27
+ # timestamp: Integer|nil, tokens: Integer, estimated: Boolean}`
28
+ def render_debug
29
+ {
30
+ role: :tool_response,
31
+ content: content,
32
+ success: payload["success"] != false,
33
+ tool_use_id: payload["tool_use_id"],
34
+ timestamp: timestamp
35
+ }.merge(token_info)
36
+ end
37
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Decorates user_message events for display in the TUI.
4
+ # Basic mode returns role and content. Verbose mode adds a timestamp.
5
+ # Debug mode adds token count (exact when counted, estimated when not).
6
+ class UserMessageDecorator < EventDecorator
7
+ # @return [Hash] structured user message data
8
+ # `{role: :user, content: String}`
9
+ def render_basic
10
+ {role: :user, content: content}
11
+ end
12
+
13
+ # @return [Hash] structured user message with nanosecond timestamp
14
+ # `{role: :user, content: String, timestamp: Integer|nil}`
15
+ def render_verbose
16
+ {role: :user, content: content, timestamp: timestamp}
17
+ end
18
+
19
+ # @return [Hash] verbose output plus token count for debugging
20
+ # `{role: :user, content: String, timestamp: Integer|nil, tokens: Integer, estimated: Boolean}`
21
+ def render_debug
22
+ render_verbose.merge(token_info)
23
+ end
24
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Executes an LLM agent loop as a background job with retry logic
4
+ # for transient failures (network errors, rate limits, server errors).
5
+ #
6
+ # Emits events via {Events::Bus} as it progresses, making results visible
7
+ # to any subscriber (TUI, WebSocket clients). All retry and failure
8
+ # notifications are emitted as {Events::SystemMessage} to avoid polluting
9
+ # the LLM context window.
10
+ #
11
+ # @example Inline execution (TUI)
12
+ # AgentRequestJob.perform_now(session.id)
13
+ #
14
+ # @example Background execution (future Brain/TUI separation)
15
+ # AgentRequestJob.perform_later(session.id)
16
+ class AgentRequestJob < ApplicationJob
17
+ queue_as :default
18
+
19
+ retry_on Providers::Anthropic::TransientError,
20
+ wait: :polynomially_longer, attempts: 5 do |job, error|
21
+ Events::Bus.emit(Events::SystemMessage.new(
22
+ content: "Failed after multiple retries: #{error.message}",
23
+ session_id: job.arguments.first
24
+ ))
25
+ end
26
+
27
+ discard_on ActiveRecord::RecordNotFound
28
+ discard_on Providers::Anthropic::AuthenticationError do |job, error|
29
+ Events::Bus.emit(Events::SystemMessage.new(
30
+ content: "Authentication failed: #{error.message}",
31
+ session_id: job.arguments.first
32
+ ))
33
+ end
34
+
35
+ # @param session_id [Integer] ID of the session to process
36
+ def perform(session_id)
37
+ session = Session.find(session_id)
38
+ agent_loop = AgentLoop.new(session: session)
39
+ agent_loop.run
40
+ ensure
41
+ agent_loop&.finalize
42
+ end
43
+
44
+ private
45
+
46
+ # Emits a system message before each retry so the user sees
47
+ # "retrying..." instead of nothing.
48
+ def retry_job(options = {})
49
+ error = options[:error]
50
+ wait = options[:wait]
51
+
52
+ Events::Bus.emit(Events::SystemMessage.new(
53
+ content: "#{error.message} — retrying in #{wait.to_i}s...",
54
+ session_id: arguments.first
55
+ ))
56
+
57
+ super
58
+ end
59
+ end
@@ -6,7 +6,7 @@
6
6
  class CountEventTokensJob < ApplicationJob
7
7
  queue_as :default
8
8
 
9
- retry_on Providers::Anthropic::Error, wait: :exponentially_longer, attempts: 3
9
+ retry_on Providers::Anthropic::Error, wait: :polynomially_longer, attempts: 3
10
10
  discard_on ActiveRecord::RecordNotFound
11
11
 
12
12
  # @param event_id [Integer] the Event record to count tokens for
data/app/models/event.rb CHANGED
@@ -22,6 +22,9 @@ class Event < ApplicationRecord
22
22
 
23
23
  ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
24
24
 
25
+ # Heuristic: average bytes per token for English prose.
26
+ BYTES_PER_TOKEN = 4
27
+
25
28
  belongs_to :session
26
29
 
27
30
  validates :event_type, presence: true, inclusion: {in: TYPES}
@@ -56,6 +59,20 @@ class Event < ApplicationRecord
56
59
  event_type.in?(CONTEXT_TYPES)
57
60
  end
58
61
 
62
+ # Heuristic token estimate: ~4 bytes per token for English prose.
63
+ # Tool events are estimated from the full payload JSON since tool_input
64
+ # and tool metadata contribute to token count. Messages use content only.
65
+ #
66
+ # @return [Integer] estimated token count (at least 1)
67
+ def estimate_tokens
68
+ text = if event_type.in?(%w[tool_call tool_response])
69
+ payload.to_json
70
+ else
71
+ payload["content"].to_s
72
+ end
73
+ [(text.bytesize / BYTES_PER_TOKEN.to_f).ceil, 1].max
74
+ end
75
+
59
76
  private
60
77
 
61
78
  def schedule_token_count
@@ -7,23 +7,29 @@ class Session < ApplicationRecord
7
7
  # Claude Sonnet 4 context window minus system prompt reserve.
8
8
  DEFAULT_TOKEN_BUDGET = 190_000
9
9
 
10
- # Heuristic: average bytes per token for English prose.
11
- BYTES_PER_TOKEN = 4
10
+ VIEW_MODES = %w[basic verbose debug].freeze
12
11
 
13
12
  has_many :events, -> { order(:id) }, dependent: :destroy
14
13
 
15
- # Builds the message array expected by the Anthropic Messages API.
16
- # Includes user/agent messages and tool call/response events in
17
- # Anthropic's wire format. Consecutive tool_call events are grouped
18
- # into a single assistant message; consecutive tool_response events
19
- # are grouped into a single user message with tool_result blocks.
14
+ validates :view_mode, inclusion: {in: VIEW_MODES}
15
+
16
+ scope :recent, ->(limit = 10) { order(updated_at: :desc).limit(limit) }
17
+
18
+ # Cycles to the next view mode: basic verbose → debug → basic.
20
19
  #
20
+ # @return [String] the next view mode in the cycle
21
+ def next_view_mode
22
+ current_index = VIEW_MODES.index(view_mode) || 0
23
+ VIEW_MODES[(current_index + 1) % VIEW_MODES.size]
24
+ end
25
+
26
+ # Returns the events currently visible in the LLM context window.
21
27
  # Walks events newest-first and includes them until the token budget
22
28
  # is exhausted. Events are full-size or excluded entirely.
23
29
  #
24
30
  # @param token_budget [Integer] maximum tokens to include (positive)
25
- # @return [Array<Hash>] Anthropic Messages API format
26
- def messages_for_llm(token_budget: DEFAULT_TOKEN_BUDGET)
31
+ # @return [Array<Event>] chronologically ordered
32
+ def viewport_events(token_budget: DEFAULT_TOKEN_BUDGET)
27
33
  selected = []
28
34
  remaining = token_budget
29
35
 
@@ -35,7 +41,28 @@ class Session < ApplicationRecord
35
41
  remaining -= cost
36
42
  end
37
43
 
38
- assemble_messages(selected.reverse)
44
+ selected.reverse
45
+ end
46
+
47
+ # Returns the assembled system prompt for this session.
48
+ # The system prompt includes system instructions, goals, and memories.
49
+ # Currently a placeholder — these subsystems are not yet implemented.
50
+ #
51
+ # @return [String, nil] the system prompt text, or nil if not configured
52
+ def system_prompt
53
+ nil
54
+ end
55
+
56
+ # Builds the message array expected by the Anthropic Messages API.
57
+ # Includes user/agent messages and tool call/response events in
58
+ # Anthropic's wire format. Consecutive tool_call events are grouped
59
+ # into a single assistant message; consecutive tool_response events
60
+ # are grouped into a single user message with tool_result blocks.
61
+ #
62
+ # @param token_budget [Integer] maximum tokens to include (positive)
63
+ # @return [Array<Hash>] Anthropic Messages API format
64
+ def messages_for_llm(token_budget: DEFAULT_TOKEN_BUDGET)
65
+ assemble_messages(viewport_events(token_budget: token_budget))
39
66
  end
40
67
 
41
68
  private
@@ -88,18 +115,12 @@ class Session < ApplicationRecord
88
115
  }
89
116
  end
90
117
 
91
- # Rough estimate for events not yet counted by the background job.
92
- # For tool events, estimates from the full payload since tool_input
93
- # and tool metadata contribute to token count.
118
+ # Delegates to {Event#estimate_tokens} for events not yet counted
119
+ # by the background job.
94
120
  #
95
121
  # @param event [Event]
96
122
  # @return [Integer] at least 1
97
123
  def estimate_tokens(event)
98
- text = if event.event_type.in?(%w[tool_call tool_response])
99
- event.payload.to_json
100
- else
101
- event.payload["content"].to_s
102
- end
103
- [(text.bytesize / BYTES_PER_TOKEN.to_f).ceil, 1].max
124
+ event.estimate_tokens
104
125
  end
105
126
  end
data/bin/jobs ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../config/environment"
4
+ require "solid_queue/cli"
5
+
6
+ SolidQueue::Cli.start(ARGV)
data/bin/rails ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ APP_PATH = File.expand_path("../config/application", __dir__)
5
+ require_relative "../config/boot"
6
+ require "rails/commands"
data/bin/rake ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../config/boot"
5
+ require "rake"
6
+ Rake.application.run
@@ -6,7 +6,10 @@ require "rails"
6
6
  require "active_model/railtie"
7
7
  require "active_record/railtie"
8
8
  require "active_job/railtie"
9
+ require "action_cable/engine"
9
10
  require "rails/test_unit/railtie"
11
+ require "draper"
12
+ require "solid_cable"
10
13
  require "solid_queue"
11
14
 
12
15
  Bundler.require(*Rails.groups) if ENV.key?("BUNDLE_GEMFILE")
@@ -20,6 +23,8 @@ module Anima
20
23
  config.active_job.queue_adapter = :solid_queue
21
24
  config.solid_queue.connects_to = {database: {writing: :queue}}
22
25
 
26
+ config.action_cable.disable_request_forgery_protection = true
27
+
23
28
  anima_home = Pathname.new(File.expand_path("~/.anima"))
24
29
 
25
30
  config.paths["log"] = [anima_home.join("log", "#{Rails.env}.log").to_s]
data/config/cable.yml ADDED
@@ -0,0 +1,14 @@
1
+ development:
2
+ adapter: solid_cable
3
+ connects_to:
4
+ database:
5
+ writing: cable
6
+
7
+ test:
8
+ adapter: test
9
+
10
+ production:
11
+ adapter: solid_cable
12
+ connects_to:
13
+ database:
14
+ writing: cable
data/config/database.yml CHANGED
@@ -13,6 +13,10 @@ development:
13
13
  <<: *default
14
14
  database: <%= File.join(anima_home, "db", "development_queue.sqlite3") %>
15
15
  migrations_paths: db/queue_migrate
16
+ cable:
17
+ <<: *default
18
+ database: <%= File.join(anima_home, "db", "development_cable.sqlite3") %>
19
+ migrations_paths: db/cable_migrate
16
20
 
17
21
  test:
18
22
  primary:
@@ -22,6 +26,10 @@ test:
22
26
  <<: *default
23
27
  database: <%= File.join(anima_home, "db", "test_queue.sqlite3") %>
24
28
  migrations_paths: db/queue_migrate
29
+ cable:
30
+ <<: *default
31
+ database: <%= File.join(anima_home, "db", "test_cable.sqlite3") %>
32
+ migrations_paths: db/cable_migrate
25
33
 
26
34
  production:
27
35
  primary:
@@ -31,3 +39,7 @@ production:
31
39
  <<: *default
32
40
  database: <%= File.join(anima_home, "db", "production_queue.sqlite3") %>
33
41
  migrations_paths: db/queue_migrate
42
+ cable:
43
+ <<: *default
44
+ database: <%= File.join(anima_home, "db", "production_cable.sqlite3") %>
45
+ migrations_paths: db/cable_migrate
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Registers global EventBus subscribers at boot time.
4
+ # Subscribers registered here receive all events regardless of which
5
+ # process emitted them (brain server, background job, etc.).
6
+ Rails.application.config.after_initialize do
7
+ # Global persister handles events from all sessions (brain server, background jobs).
8
+ # Skipped in test — specs manage their own persisters for isolation.
9
+ Events::Bus.subscribe(Events::Subscribers::Persister.new) unless Rails.env.test?
10
+ Events::Bus.subscribe(Events::Subscribers::ActionCableBridge.instance)
11
+ end
data/config/puma.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Anima brain server — serves Action Cable WebSocket connections
4
+ # and health check endpoint. Port 42134 by default.
5
+
6
+ threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
7
+ threads threads_count, threads_count
8
+
9
+ port ENV.fetch("PORT", 42134)
10
+
11
+ pidfile ENV.fetch("PIDFILE", File.expand_path("~/.anima/tmp/pids/puma.pid"))
12
+
13
+ plugin :tmp_restart
data/config/routes.rb CHANGED
@@ -1,4 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  Rails.application.routes.draw do
4
+ mount ActionCable.server => "/cable"
5
+ get "up", to: "rails/health#show", as: :rails_health_check
6
+
7
+ namespace :api do
8
+ resources :sessions, only: [:create] do
9
+ get :current, on: :collection
10
+ end
11
+ end
4
12
  end
data/config.ru ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "config/environment"
4
+
5
+ run Rails.application
@@ -0,0 +1,23 @@
1
+ # This file is auto-generated from the current state of the database. Instead
2
+ # of editing this file, please use the migrations feature of Active Record to
3
+ # incrementally modify your database, and then regenerate this schema definition.
4
+ #
5
+ # This file is the source Rails uses to define your schema when running `bin/rails
6
+ # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
7
+ # be faster and is potentially less error prone than running all of your
8
+ # migrations from scratch. Old migrations may fail to apply correctly if those
9
+ # migrations use external dependencies or application code.
10
+ #
11
+ # It's strongly recommended that you check this file into your version control system.
12
+
13
+ ActiveRecord::Schema[8.1].define(version: 1) do
14
+ create_table "solid_cable_messages", force: :cascade do |t|
15
+ t.binary "channel", limit: 1024, null: false
16
+ t.integer "channel_hash", limit: 8, null: false
17
+ t.datetime "created_at", null: false
18
+ t.binary "payload", limit: 536870912, null: false
19
+ t.index ["channel"], name: "index_solid_cable_messages_on_channel"
20
+ t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
21
+ t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
22
+ end
23
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddViewModeToSessions < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :sessions, :view_mode, :string, default: "basic", null: false
6
+ end
7
+ end