anima-core 0.0.1 → 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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +18 -0
  3. data/CHANGELOG.md +26 -0
  4. data/README.md +134 -19
  5. data/Rakefile +3 -0
  6. data/app/jobs/application_job.rb +4 -0
  7. data/app/jobs/count_event_tokens_job.rb +28 -0
  8. data/app/models/application_record.rb +5 -0
  9. data/app/models/event.rb +64 -0
  10. data/app/models/session.rb +105 -0
  11. data/config/application.rb +31 -0
  12. data/config/boot.rb +8 -0
  13. data/config/database.yml +33 -0
  14. data/config/environment.rb +5 -0
  15. data/config/environments/development.rb +8 -0
  16. data/config/environments/production.rb +8 -0
  17. data/config/environments/test.rb +9 -0
  18. data/config/initializers/inflections.rb +9 -0
  19. data/config/queue.yml +18 -0
  20. data/config/recurring.yml +15 -0
  21. data/config/routes.rb +4 -0
  22. data/db/migrate/.keep +0 -0
  23. data/db/migrate/20260308124202_create_sessions.rb +9 -0
  24. data/db/migrate/20260308124203_create_events.rb +18 -0
  25. data/db/migrate/20260308130000_add_event_indexes.rb +9 -0
  26. data/db/migrate/20260308140000_remove_position_from_events.rb +8 -0
  27. data/db/migrate/20260308150000_add_token_count_to_events.rb +7 -0
  28. data/db/migrate/20260308160000_add_tool_use_id_to_events.rb +8 -0
  29. data/db/queue_schema.rb +141 -0
  30. data/db/seeds.rb +1 -0
  31. data/exe/anima +6 -0
  32. data/lib/anima/cli.rb +55 -0
  33. data/lib/anima/installer.rb +118 -0
  34. data/lib/anima/version.rb +1 -1
  35. data/lib/anima.rb +4 -0
  36. data/lib/events/agent_message.rb +11 -0
  37. data/lib/events/base.rb +38 -0
  38. data/lib/events/bus.rb +39 -0
  39. data/lib/events/subscriber.rb +26 -0
  40. data/lib/events/subscribers/message_collector.rb +64 -0
  41. data/lib/events/subscribers/persister.rb +46 -0
  42. data/lib/events/system_message.rb +11 -0
  43. data/lib/events/tool_call.rb +29 -0
  44. data/lib/events/tool_response.rb +33 -0
  45. data/lib/events/user_message.rb +11 -0
  46. data/lib/llm/client.rb +161 -0
  47. data/lib/providers/anthropic.rb +164 -0
  48. data/lib/shell_session.rb +333 -0
  49. data/lib/tools/base.rb +58 -0
  50. data/lib/tools/bash.rb +53 -0
  51. data/lib/tools/registry.rb +60 -0
  52. data/lib/tools/web_get.rb +62 -0
  53. data/lib/tui/app.rb +181 -0
  54. data/lib/tui/screens/anthropic.rb +25 -0
  55. data/lib/tui/screens/chat.rb +210 -0
  56. data/lib/tui/screens/settings.rb +52 -0
  57. metadata +124 -4
  58. data/BRAINSTORM.md +0 -466
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1093dd7268e23c30a34d081b91ae453080aba47f5864ed285baf3a4d637ddabe
4
- data.tar.gz: c75a06cca5222689a4b204846eca8843c1ac912608dec8a8b3898443f6219728
3
+ metadata.gz: 6f7039906ee15401ed13db1ceaa640b088540903b1f0b814463fe69d91f6fa02
4
+ data.tar.gz: dee3280a42efa74aa78b3499f8c6687a937287eda9099e2b7de09d4ccf761021
5
5
  SHA512:
6
- metadata.gz: 2e5b40e05fdd0a193adf362d4eda84d83ddfe51f379b8ebb3cc17070af733371526632946ecd577756815d10bd410e919a3013a942821ccf1f4c9fea966bfbe8
7
- data.tar.gz: b28b6f7e60478e06bedeb8a87a4ce682dd65a9111007626565af515518f614e682b18bd26adb59ba6aa3d9dd1bc5c468c605229c880b4da0ad3ccdd96257e76a
6
+ metadata.gz: 1ed4a8551e9ba05919e95a4879bf2366e9cd4986726ebe0ed5900007bd6d06a53639133be5a286d38e41a7f269db78b3575ff982c23d2b4096f804c3528b02a0
7
+ data.tar.gz: 65f6d5dbe475dd36af817a3f2eb88c6f6913604de6bfc9dd56775d18abf8e47e0269862e669a8f739ee57fa99a9aede77d343c692400a44a4e873d28ee482c7a
data/.reek.yml ADDED
@@ -0,0 +1,18 @@
1
+ ---
2
+ detectors:
3
+ # YARD docs cover module/class documentation; no need for reek to duplicate
4
+ IrresponsibleModule:
5
+ enabled: false
6
+ # Constructor injection (provider: nil) triggers false positives
7
+ ControlParameter:
8
+ enabled: false
9
+ # Bang methods don't need safe counterparts in this codebase
10
+ MissingSafeMethod:
11
+ enabled: false
12
+ # Private helpers don't need instance state to be valid
13
+ UtilityFunction:
14
+ public_methods_only: true
15
+
16
+ # TUI render methods receive (frame, tui) by design — RatatuiRuby convention
17
+ exclude_paths:
18
+ - lib/tui
data/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ### Added
4
+ - Session and event persistence to SQLite — conversations survive TUI restart
5
+ - `Session` model — owns an ordered event stream
6
+ - `Event` model — polymorphic type, JSON payload, auto-incrementing position
7
+ - `Events::Subscribers::Persister` — writes all events to SQLite as they flow through the bus
8
+ - TUI resumes last session on startup, `Ctrl+a > n` creates a new session
9
+ - `anima tui` now runs pending migrations automatically on launch
10
+ - Event system using Rails Structured Event Reporter (`Rails.event`)
11
+ - Five event types: `system_message`, `user_message`, `agent_message`, `tool_call`, `tool_response`
12
+ - `Events::Bus` — thin wrapper around `Rails.event` for emitting and subscribing to Anima events
13
+ - `Events::Subscribers::MessageCollector` — in-memory subscriber that collects displayable messages
14
+ - Chat screen refactored from raw array to event-driven architecture
15
+ - TUI chat screen with LLM integration — in-memory message array, threaded API calls
16
+ - Chat input with character validation, backspace, Enter to submit
17
+ - Loading indicator — "Thinking" status bar mode, grayed-out input during LLM calls
18
+ - New session command (`Ctrl+a > n`) clears conversation
19
+ - Error handling — API failures displayed inline as chat messages
20
+ - Anthropic API subscription token authentication
21
+ - LLM client (raw HTTP to Anthropic API)
22
+ - TUI scaffold with RatatuiRuby — tmux-style `Ctrl+a` command mode, sidebar, status bar
23
+ - Headless Rails 8.1 app (API-only, no views/assets/Action Cable)
24
+ - `anima install` command — creates ~/.anima/ tree, per-environment credentials, systemd user service
25
+ - `anima start` command — runs db:prepare and boots Rails
26
+ - SQLite databases, logs, tmp, and credentials stored in ~/.anima/
27
+ - Environment validation (development, test, production)
28
+
3
29
  ## [0.0.1] - 2026-03-06
4
30
 
5
31
  - Initial gem scaffold with CI and RubyGems publishing
data/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  **A soul engine for AI agents.**
4
4
 
5
5
  Ruby framework for building AI agents with desires, personality, and personal growth.
6
- Powered by [Rage](https://rage-rb.dev/).
6
+ Headless Rails 8.1 app distributed as a gem, with a TUI-first interface via RatatuiRuby.
7
7
 
8
8
  ## The Problem
9
9
 
@@ -61,26 +61,110 @@ Existing RL techniques apply at the starting point, then we gradually expand int
61
61
  ## Architecture
62
62
 
63
63
  ```
64
- Anima Framework (Ruby, Rage-based)
64
+ Anima Framework (Ruby, Rails 8.1 headless)
65
65
  ├── Thymos — hormonal/desire system (stimulus → hormone vector)
66
66
  ├── Mneme — semantic memory (QMD-style, emotional recall)
67
67
  ├── Psyche — soul matrix (coefficient table, evolving through experience)
68
68
  └── Nous — LLM integration (cortex, thinking, decision-making)
69
69
  ```
70
70
 
71
+ ### Tech Stack
72
+
73
+ | Component | Technology |
74
+ |-----------|-----------|
75
+ | Framework | Rails 8.1 (headless — no web views, no asset pipeline) |
76
+ | Database | SQLite (per environment, stored in `~/.anima/db/`) |
77
+ | Event system | Rails Structured Event Reporter |
78
+ | LLM integration | Raw HTTP to Anthropic API |
79
+ | Interface | TUI via RatatuiRuby |
80
+ | Distribution | RubyGems (`gem install anima-core`) |
81
+
82
+ ### Distribution Model
83
+
84
+ Anima is a Rails app distributed as a gem, following Unix philosophy: immutable program separate from mutable data.
85
+
86
+ ```bash
87
+ gem install anima-core # Install the Rails app as a gem
88
+ anima install # Create ~/.anima/ directory structure
89
+ anima start # Launch — code from gem, state from ~/.anima/
90
+ ```
91
+
92
+ State directory (`~/.anima/`):
93
+ ```
94
+ ~/.anima/
95
+ ├── db/ # SQLite databases (production, development, test)
96
+ ├── config/
97
+ │ ├── credentials/ # Rails encrypted credentials per environment
98
+ │ └── anima.yml # User configuration
99
+ ├── log/
100
+ └── tmp/
101
+ ```
102
+
103
+ Updates: `gem update anima-core` — next launch runs pending migrations automatically.
104
+
105
+ ### Authentication Setup
106
+
107
+ Anima uses your Claude Pro/Max subscription for API access. You need a setup-token from Claude Code CLI.
108
+
109
+ **1. Get a setup-token:**
110
+
111
+ ```bash
112
+ claude setup-token
113
+ ```
114
+
115
+ This requires [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and a Claude Pro or Max subscription.
116
+
117
+ **2. Store the token in Anima credentials:**
118
+
119
+ ```bash
120
+ cd $(gem contents anima-core | head -1 | xargs dirname | xargs dirname)
121
+ bin/rails credentials:edit
122
+ ```
123
+
124
+ Add your token:
125
+
126
+ ```yaml
127
+ anthropic:
128
+ subscription_token: sk-ant-oat01-YOUR_TOKEN_HERE
129
+ ```
130
+
131
+ **3. Verify the token works:**
132
+
133
+ ```bash
134
+ bin/rails runner "Providers::Anthropic.validate!"
135
+ ```
136
+
137
+ If the token expires or is revoked, repeat steps 1-2 with a new token.
138
+
71
139
  ### Three Layers (mirroring biology)
72
140
 
73
141
  1. **Endocrine system (Thymos)** — a lightweight background process. Reads recent events. Doesn't respond. Just updates hormone levels. Pure stimulus→response, like a biological gland.
74
142
 
75
- 2. **Homeostasis** — persistent state (JSON/SQLite). Current hormone levels with decay functions. No intelligence, just state that changes over time.
143
+ 2. **Homeostasis** — persistent state (SQLite). Current hormone levels with decay functions. No intelligence, just state that changes over time.
76
144
 
77
145
  3. **Cortex (Nous)** — the main LLM. Reads hormone state transformed into **desire descriptions**. Not "longing: 87" but "you want to see them". The LLM should NOT see raw numbers — humans don't see cortisol levels, they feel anxiety.
78
146
 
79
147
  ### Event-Driven Design
80
148
 
81
- Built on [Rage](https://rage-rb.dev/) a Ruby framework with fiber-based concurrency, native WebSockets, and a built-in event bus. The event bus maps directly to a nervous system: stimuli fire events, Thymos subscribers update hormone levels, Nous reacts to the resulting desires.
149
+ Built on Rails Structured Event Reporter a native Rails 8.1 feature for structured event emission with typed payloads, subscriber patterns, and block-scoped context tagging.
150
+
151
+ Five event types form the agent's nervous system:
152
+
153
+ | Event | Purpose |
154
+ |-------|---------|
155
+ | `system_message` | Internal notifications |
156
+ | `user_message` | User input |
157
+ | `agent_message` | LLM response |
158
+ | `tool_call` | Tool invocation |
159
+ | `tool_response` | Tool result |
160
+
161
+ Events fire, subscribers react, state updates, the cortex (LLM) reads the resulting desire landscape. The system prompt is assembled separately for each LLM call — it is not an event.
162
+
163
+ ### Context as Viewport, Not Tape
82
164
 
83
- Single-process architecture: web server, background hormone ticks, WebSocket monitoringall in one process, no Redis, no external workers.
165
+ There is no linear chat history. There are only events attached to a session. The context window is a **viewport** a sliding window over the event stream, assembled on demand for each LLM call within a configured token budget.
166
+
167
+ POC uses a simple sliding window (newest events first, walk backwards until budget exhausted). Future versions will add multi-resolution compression with Draper decorators and associative recall from Mneme.
84
168
 
85
169
  ### Brain as Microservices on a Shared Event Bus
86
170
 
@@ -95,7 +179,7 @@ Event: "tool_call_failed"
95
179
  ├── Mneme subscriber: log failure context for future recall
96
180
  └── Psyche subscriber: update coefficient (this agent handles errors calmly → low frustration_gain)
97
181
 
98
- Event: "user_sent_message"
182
+ Event: "user_sent_message"
99
183
 
100
184
  ├── Thymos subscriber: oxytocin += 5 (bonding signal)
101
185
  ├── Thymos subscriber: dopamine += 3 (engagement signal)
@@ -104,7 +188,17 @@ Event: "user_sent_message"
104
188
 
105
189
  Each subscriber is a microservice — independent, stateless, reacting to the same event bus. No orchestrator decides "now update frustration." The architecture IS the nervous system.
106
190
 
107
- This is why Rage's built-in event bus maps so naturally: `Rage.event_bus` IS the nervous system. Events fire, subscribers react, state updates, the cortex (LLM) reads the resulting desire landscape.
191
+ ### Plugin Architecture
192
+
193
+ Both tools and feelings are distributed as gems on the event bus:
194
+
195
+ ```bash
196
+ anima add anima-tools-filesystem
197
+ anima add anima-tools-shell
198
+ anima add anima-feelings-frustration
199
+ ```
200
+
201
+ Tools provide MCP capabilities. Feelings are event subscribers that update hormonal state. Same mechanism, different namespace. In POC, tools are built-in; plugin extraction comes later.
108
202
 
109
203
  ### Semantic Memory (Mneme)
110
204
 
@@ -207,17 +301,38 @@ This single example demonstrates every core principle:
207
301
 
208
302
  ## Status
209
303
 
210
- Idea stage early design. Architecture research underway (OpenClaw agent loop documented).
211
- First practical hormone (frustration) designed, ready for prototyping.
304
+ POC stage. Gem scaffold with CI and RubyGems publishing exists. Building toward a working conversational agent with event-driven architecture.
305
+
306
+ The hormonal system (Thymos, feelings, desires), semantic memory (Mneme), and soul matrix (Psyche) are designed but deferred — POC focuses on getting the core agent loop working first.
307
+
308
+ ## Development
309
+
310
+ ```bash
311
+ git clone https://github.com/hoblin/anima.git
312
+ cd anima
313
+ bundle install
314
+ bundle exec rspec
315
+ ```
316
+
317
+ ### Running the TUI
318
+
319
+ The TUI requires a background job worker for async token counting (used by the sliding viewport). Start both in separate terminals:
320
+
321
+ ```bash
322
+ # Terminal 1: Start Solid Queue worker
323
+ RAILS_ENV=development bundle exec rake solid_queue:start
324
+
325
+ # Terminal 2: Launch the TUI
326
+ bundle exec anima tui
327
+ ```
328
+
329
+ On first run, initialize the databases:
330
+
331
+ ```bash
332
+ RAILS_ENV=development bundle exec rails db:migrate
333
+ RAILS_ENV=development bundle exec rails db:schema:load:queue
334
+ ```
212
335
 
213
- ## Next Steps
336
+ ## License
214
337
 
215
- - [ ] **MVP: Frustration hormone** — monitor tool calls, adjust thinking budget + inner voice injection
216
- - [ ] Research prior art in depth (affective computing, BDI architecture, virtual creature motivation)
217
- - [ ] Design initial coefficient matrix schema (Psyche)
218
- - [ ] Prototype Thymos: Rage event bus + JSON state + context injection into LLM thinking step
219
- - [ ] Experiment: hormone names vs abstract parameter names in LLM prompts
220
- - [ ] Set up Rage project skeleton with event bus
221
- - [ ] Design full event taxonomy (what events does the agent's "nervous system" react to?)
222
- - [ ] Build Mneme: semantic memory with emotional associations
223
- - [ ] Write blog post introducing the concept
338
+ MIT License. See [LICENSE.txt](LICENSE.txt).
data/Rakefile CHANGED
@@ -7,4 +7,7 @@ RSpec::Core::RakeTask.new(:spec)
7
7
 
8
8
  require "standard/rake"
9
9
 
10
+ require_relative "config/application"
11
+ Rails.application.load_tasks
12
+
10
13
  task default: %i[spec standard]
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationJob < ActiveJob::Base
4
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Counts tokens in an event's payload via the Anthropic API and
4
+ # caches the result on the event record. Enqueued automatically
5
+ # after each LLM event is created.
6
+ class CountEventTokensJob < ApplicationJob
7
+ queue_as :default
8
+
9
+ retry_on Providers::Anthropic::Error, wait: :exponentially_longer, attempts: 3
10
+ discard_on ActiveRecord::RecordNotFound
11
+
12
+ # @param event_id [Integer] the Event record to count tokens for
13
+ def perform(event_id)
14
+ event = Event.find(event_id)
15
+ return if event.token_count > 0
16
+
17
+ provider = Providers::Anthropic.new
18
+ messages = [{role: event.api_role, content: event.payload["content"].to_s}]
19
+
20
+ token_count = provider.count_tokens(
21
+ model: LLM::Client::DEFAULT_MODEL,
22
+ messages: messages
23
+ )
24
+
25
+ # Atomic update: only write if still uncounted (avoids race with parallel jobs).
26
+ Event.where(id: event.id, token_count: 0).update_all(token_count: token_count)
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationRecord < ActiveRecord::Base
4
+ self.abstract_class = true
5
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A persisted record of something that happened during a session.
4
+ # Events are the single source of truth for conversation history —
5
+ # there is no separate chat log, only events attached to a session.
6
+ #
7
+ # @!attribute event_type
8
+ # @return [String] one of {TYPES}: system_message, user_message,
9
+ # agent_message, tool_call, tool_response
10
+ # @!attribute payload
11
+ # @return [Hash] event-specific data (content, tool_name, tool_input, etc.)
12
+ # @!attribute timestamp
13
+ # @return [Integer] nanoseconds since epoch (Process::CLOCK_REALTIME)
14
+ # @!attribute token_count
15
+ # @return [Integer] cached token count for this event's payload (0 until counted)
16
+ # @!attribute tool_use_id
17
+ # @return [String, nil] Anthropic-assigned ID correlating tool_call and tool_response
18
+ class Event < ApplicationRecord
19
+ TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
20
+ LLM_TYPES = %w[user_message agent_message].freeze
21
+ CONTEXT_TYPES = %w[user_message agent_message tool_call tool_response].freeze
22
+
23
+ ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
24
+
25
+ belongs_to :session
26
+
27
+ validates :event_type, presence: true, inclusion: {in: TYPES}
28
+ validates :payload, presence: true
29
+ validates :timestamp, presence: true
30
+
31
+ after_create :schedule_token_count, if: :llm_message?
32
+
33
+ # @!method self.llm_messages
34
+ # Events that represent conversation turns sent to the LLM API.
35
+ # @return [ActiveRecord::Relation]
36
+ scope :llm_messages, -> { where(event_type: LLM_TYPES) }
37
+
38
+ # @!method self.context_events
39
+ # Events included in the LLM context window (messages + tool interactions).
40
+ # @return [ActiveRecord::Relation]
41
+ scope :context_events, -> { where(event_type: CONTEXT_TYPES) }
42
+
43
+ # Maps event_type to the Anthropic Messages API role.
44
+ # @return [String] "user" or "assistant"
45
+ def api_role
46
+ ROLE_MAP.fetch(event_type)
47
+ end
48
+
49
+ # @return [Boolean] true if this event represents an LLM conversation turn
50
+ def llm_message?
51
+ event_type.in?(LLM_TYPES)
52
+ end
53
+
54
+ # @return [Boolean] true if this event is part of the LLM context window
55
+ def context_event?
56
+ event_type.in?(CONTEXT_TYPES)
57
+ end
58
+
59
+ private
60
+
61
+ def schedule_token_count
62
+ CountEventTokensJob.perform_later(id)
63
+ end
64
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A conversation session — the fundamental unit of agent interaction.
4
+ # Owns an ordered stream of {Event} records representing everything
5
+ # that happened: user messages, agent responses, tool calls, etc.
6
+ class Session < ApplicationRecord
7
+ # Claude Sonnet 4 context window minus system prompt reserve.
8
+ DEFAULT_TOKEN_BUDGET = 190_000
9
+
10
+ # Heuristic: average bytes per token for English prose.
11
+ BYTES_PER_TOKEN = 4
12
+
13
+ has_many :events, -> { order(:id) }, dependent: :destroy
14
+
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.
20
+ #
21
+ # Walks events newest-first and includes them until the token budget
22
+ # is exhausted. Events are full-size or excluded entirely.
23
+ #
24
+ # @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)
27
+ selected = []
28
+ remaining = token_budget
29
+
30
+ events.context_events.reorder(id: :desc).each do |event|
31
+ cost = (event.token_count > 0) ? event.token_count : estimate_tokens(event)
32
+ break if cost > remaining && selected.any?
33
+
34
+ selected << event
35
+ remaining -= cost
36
+ end
37
+
38
+ assemble_messages(selected.reverse)
39
+ end
40
+
41
+ private
42
+
43
+ # Converts a chronological list of events into Anthropic wire-format messages.
44
+ # Groups consecutive tool_call events into one assistant message and
45
+ # consecutive tool_response events into one user message.
46
+ #
47
+ # @param events [Array<Event>]
48
+ # @return [Array<Hash>]
49
+ def assemble_messages(events)
50
+ events.each_with_object([]) do |event, messages|
51
+ case event.event_type
52
+ when "user_message"
53
+ messages << {role: "user", content: event.payload["content"].to_s}
54
+ when "agent_message"
55
+ messages << {role: "assistant", content: event.payload["content"].to_s}
56
+ when "tool_call"
57
+ append_grouped_block(messages, "assistant", tool_use_block(event.payload))
58
+ when "tool_response"
59
+ append_grouped_block(messages, "user", tool_result_block(event.payload))
60
+ end
61
+ end
62
+ end
63
+
64
+ # Groups consecutive tool blocks into a single message of the given role.
65
+ def append_grouped_block(messages, role, block)
66
+ prev = messages.last
67
+ if prev&.dig(:role) == role && prev[:content].is_a?(Array)
68
+ prev[:content] << block
69
+ else
70
+ messages << {role: role, content: [block]}
71
+ end
72
+ end
73
+
74
+ def tool_use_block(payload)
75
+ {
76
+ type: "tool_use",
77
+ id: payload["tool_use_id"],
78
+ name: payload["tool_name"],
79
+ input: payload["tool_input"] || {}
80
+ }
81
+ end
82
+
83
+ def tool_result_block(payload)
84
+ {
85
+ type: "tool_result",
86
+ tool_use_id: payload["tool_use_id"],
87
+ content: payload["content"].to_s
88
+ }
89
+ end
90
+
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.
94
+ #
95
+ # @param event [Event]
96
+ # @return [Integer] at least 1
97
+ 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
104
+ end
105
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "boot"
4
+
5
+ require "rails"
6
+ require "active_model/railtie"
7
+ require "active_record/railtie"
8
+ require "active_job/railtie"
9
+ require "rails/test_unit/railtie"
10
+ require "solid_queue"
11
+
12
+ Bundler.require(*Rails.groups) if ENV.key?("BUNDLE_GEMFILE")
13
+
14
+ module Anima
15
+ class Application < Rails::Application
16
+ config.load_defaults 8.1
17
+ config.api_only = true
18
+
19
+ config.autoload_lib(ignore: %w[anima])
20
+ config.active_job.queue_adapter = :solid_queue
21
+ config.solid_queue.connects_to = {database: {writing: :queue}}
22
+
23
+ anima_home = Pathname.new(File.expand_path("~/.anima"))
24
+
25
+ config.paths["log"] = [anima_home.join("log", "#{Rails.env}.log").to_s]
26
+ config.paths["tmp"] = [anima_home.join("tmp").to_s]
27
+
28
+ config.credentials.content_path = anima_home.join("config/credentials/#{Rails.env}.yml.enc")
29
+ config.credentials.key_path = anima_home.join("config/credentials/#{Rails.env}.key")
30
+ end
31
+ end
data/config/boot.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ gemfile = File.expand_path("../Gemfile", __dir__)
4
+
5
+ if File.exist?(gemfile)
6
+ ENV["BUNDLE_GEMFILE"] ||= gemfile
7
+ require "bundler/setup"
8
+ end
@@ -0,0 +1,33 @@
1
+ <% anima_home = File.expand_path("~/.anima") %>
2
+
3
+ default: &default
4
+ adapter: sqlite3
5
+ pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
6
+ timeout: 5000
7
+
8
+ development:
9
+ primary:
10
+ <<: *default
11
+ database: <%= File.join(anima_home, "db", "development.sqlite3") %>
12
+ queue:
13
+ <<: *default
14
+ database: <%= File.join(anima_home, "db", "development_queue.sqlite3") %>
15
+ migrations_paths: db/queue_migrate
16
+
17
+ test:
18
+ primary:
19
+ <<: *default
20
+ database: <%= File.join(anima_home, "db", "test.sqlite3") %>
21
+ queue:
22
+ <<: *default
23
+ database: <%= File.join(anima_home, "db", "test_queue.sqlite3") %>
24
+ migrations_paths: db/queue_migrate
25
+
26
+ production:
27
+ primary:
28
+ <<: *default
29
+ database: <%= File.join(anima_home, "db", "production.sqlite3") %>
30
+ queue:
31
+ <<: *default
32
+ database: <%= File.join(anima_home, "db", "production_queue.sqlite3") %>
33
+ migrations_paths: db/queue_migrate
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "application"
4
+
5
+ Rails.application.initialize!
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.configure do
4
+ config.enable_reloading = true
5
+ config.eager_load = false
6
+ config.consider_all_requests_local = true
7
+ config.active_support.deprecation = :log
8
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.configure do
4
+ config.enable_reloading = false
5
+ config.eager_load = true
6
+ config.log_level = :info
7
+ config.active_support.deprecation = :notify
8
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.configure do
4
+ config.enable_reloading = false
5
+ config.eager_load = false
6
+ config.consider_all_requests_local = true
7
+ config.active_support.deprecation = :stderr
8
+ config.active_job.queue_adapter = :test
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Zeitwerk inflections for acronym directories (e.g. lib/llm/ → LLM::)
4
+ Rails.autoloaders.each do |autoloader|
5
+ autoloader.inflector.inflect(
6
+ "llm" => "LLM",
7
+ "tui" => "TUI"
8
+ )
9
+ end
data/config/queue.yml ADDED
@@ -0,0 +1,18 @@
1
+ default: &default
2
+ dispatchers:
3
+ - polling_interval: 1
4
+ batch_size: 500
5
+ workers:
6
+ - queues: "*"
7
+ threads: 3
8
+ processes: <%= ENV.fetch("JOB_CONCURRENCY", 1) %>
9
+ polling_interval: 0.1
10
+
11
+ development:
12
+ <<: *default
13
+
14
+ test:
15
+ <<: *default
16
+
17
+ production:
18
+ <<: *default
@@ -0,0 +1,15 @@
1
+ # examples:
2
+ # periodic_cleanup:
3
+ # class: CleanSoftDeletedRecordsJob
4
+ # queue: background
5
+ # args: [ 1000, { batch_size: 500 } ]
6
+ # schedule: every hour
7
+ # periodic_cleanup_with_command:
8
+ # command: "SoftDeletedRecord.due.delete_all"
9
+ # priority: 2
10
+ # schedule: at 5am every day
11
+
12
+ production:
13
+ clear_solid_queue_finished_jobs:
14
+ command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
15
+ schedule: every hour at minute 12
data/config/routes.rb ADDED
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ Rails.application.routes.draw do
4
+ end
data/db/migrate/.keep ADDED
File without changes
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateSessions < ActiveRecord::Migration[8.1]
4
+ def change
5
+ create_table :sessions do |t|
6
+ t.timestamps
7
+ end
8
+ end
9
+ end