anima-core 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f7039906ee15401ed13db1ceaa640b088540903b1f0b814463fe69d91f6fa02
4
- data.tar.gz: dee3280a42efa74aa78b3499f8c6687a937287eda9099e2b7de09d4ccf761021
3
+ metadata.gz: fcb9e1d40357cd0eabdc5fffa01f8727b449a5b85e6f7b7dbe9033fae461bec9
4
+ data.tar.gz: d785a36f13e3a3e698b80dd123544ca6dbee535372b92776a5b7993e4125baa1
5
5
  SHA512:
6
- metadata.gz: 1ed4a8551e9ba05919e95a4879bf2366e9cd4986726ebe0ed5900007bd6d06a53639133be5a286d38e41a7f269db78b3575ff982c23d2b4096f804c3528b02a0
7
- data.tar.gz: 65f6d5dbe475dd36af817a3f2eb88c6f6913604de6bfc9dd56775d18abf8e47e0269862e669a8f739ee57fa99a9aede77d343c692400a44a4e873d28ee482c7a
6
+ metadata.gz: d84d91dc67fe56617f294b9a6982e830ac36c242fdb203bfa601c2e6fb009dd8a3d9d5243c4bfa501d7069e40bb08d15d920717374a79825ff1af7b4812713de
7
+ data.tar.gz: d61f7ed56737c02f4412bcdee5d15e9b4b8e3b63f45011aa9e8ae2c1a80725413841ad1ed95d7f02ab6a8e532e0a36f0fd9abc39b50adb5fed0dbb21ec599604
data/CHANGELOG.md CHANGED
@@ -1,12 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-03-10
4
+
3
5
  ### Added
6
+ - Client-server architecture — Brain (Rails + Puma) runs as persistent service, TUI connects via WebSocket
7
+ - Action Cable infrastructure with Solid Cable adapter for Brain/TUI WebSocket communication
8
+ - `SessionChannel` — WebSocket channel for session management, message relay, and session switching
9
+ - Graceful TUI reconnection with exponential backoff (up to 10 attempts, max 30s delay)
10
+ - `AgentRequestJob` — background job for LLM agent loops with retry logic for transient failures (network errors, rate limits, server errors)
11
+ - Provider error hierarchy — `TransientError`, `RateLimitError`, `ServerError` for retry classification; `AuthenticationError` for immediate discard
12
+ - `AgentLoop#run` — retry-safe entry point for job callers; lets errors propagate for external retry handling
13
+ - `AgentLoop` service — decouples LLM orchestration from TUI; callable from background jobs, Action Cable channels, or TUI directly
4
14
  - Session and event persistence to SQLite — conversations survive TUI restart
5
15
  - `Session` model — owns an ordered event stream
6
16
  - `Event` model — polymorphic type, JSON payload, auto-incrementing position
7
17
  - `Events::Subscribers::Persister` — writes all events to SQLite as they flow through the bus
8
18
  - TUI resumes last session on startup, `Ctrl+a > n` creates a new session
9
- - `anima tui` now runs pending migrations automatically on launch
10
19
  - Event system using Rails Structured Event Reporter (`Rails.event`)
11
20
  - Five event types: `system_message`, `user_message`, `agent_message`, `tool_call`, `tool_response`
12
21
  - `Events::Bus` — thin wrapper around `Rails.event` for emitting and subscribing to Anima events
@@ -20,9 +29,10 @@
20
29
  - Anthropic API subscription token authentication
21
30
  - LLM client (raw HTTP to Anthropic API)
22
31
  - 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)
32
+ - Headless Rails 8.1 app (API-only, no views/assets)
24
33
  - `anima install` command — creates ~/.anima/ tree, per-environment credentials, systemd user service
25
- - `anima start` command — runs db:prepare and boots Rails
34
+ - `anima start` command — runs db:prepare and boots Rails via Foreman
35
+ - Systemd user service — auto-enables and starts brain on `anima install`
26
36
  - SQLite databases, logs, tmp, and credentials stored in ~/.anima/
27
37
  - Environment validation (development, test, production)
28
38
 
data/Gemfile ADDED
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ # Specify your gem's dependencies in anima-core.gemspec
6
+ gemspec
7
+
8
+ gem "irb"
9
+ gem "rake", "~> 13.0"
10
+
11
+ gem "rspec", "~> 3.0"
12
+ gem "rspec-rails", "~> 7.0"
13
+
14
+ gem "reek", "~> 6.5"
15
+ gem "standard", "~> 1.3"
16
+
17
+ gem "webmock", "~> 3.23"
data/Procfile ADDED
@@ -0,0 +1,2 @@
1
+ web: bin/rails server
2
+ worker: bin/jobs
data/Procfile.dev ADDED
@@ -0,0 +1,2 @@
1
+ web: bin/rails server
2
+ worker: bin/jobs
data/README.md CHANGED
@@ -1,9 +1,8 @@
1
- # Anima Framework
1
+ # Anima
2
2
 
3
- **A soul engine for AI agents.**
3
+ **A personal AI agent that actually wants things.**
4
4
 
5
- Ruby framework for building AI agents with desires, personality, and personal growth.
6
- Headless Rails 8.1 app distributed as a gem, with a TUI-first interface via RatatuiRuby.
5
+ Your agent. Your machine. Your rules. Anima is an AI agent with desires, personality, and personal growth — running locally as a headless Rails 8.1 app with a client-server architecture and TUI interface.
7
6
 
8
7
  ## The Problem
9
8
 
@@ -61,22 +60,41 @@ Existing RL techniques apply at the starting point, then we gradually expand int
61
60
  ## Architecture
62
61
 
63
62
  ```
64
- Anima Framework (Ruby, Rails 8.1 headless)
63
+ Anima (Ruby, Rails 8.1 headless)
65
64
  ├── Thymos — hormonal/desire system (stimulus → hormone vector)
66
65
  ├── Mneme — semantic memory (QMD-style, emotional recall)
67
66
  ├── Psyche — soul matrix (coefficient table, evolving through experience)
68
67
  └── Nous — LLM integration (cortex, thinking, decision-making)
69
68
  ```
70
69
 
70
+ ### Runtime Architecture
71
+
72
+ Anima runs as a client-server system:
73
+
74
+ ```
75
+ Brain Server (Rails + Puma) TUI Client (RatatuiRuby)
76
+ ├── LLM integration (Anthropic) ├── WebSocket client
77
+ ├── Agent loop + tool execution ├── Terminal rendering
78
+ ├── Event bus + persistence └── User input capture
79
+ ├── Solid Queue (background jobs)
80
+ ├── Action Cable (WebSocket server)
81
+ └── SQLite databases ◄── WebSocket (port 42134) ──► TUI
82
+ ```
83
+
84
+ The **Brain** is the persistent service — it handles LLM calls, tool execution, event processing, and state. The **TUI** is a stateless client — it connects via WebSocket, renders events, and captures input. If TUI disconnects, the brain keeps running. TUI reconnects automatically with exponential backoff and resumes the session with chat history preserved.
85
+
71
86
  ### Tech Stack
72
87
 
73
88
  | Component | Technology |
74
89
  |-----------|-----------|
75
90
  | 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 |
91
+ | Database | SQLite (3 databases per environment: primary, queue, cable) |
92
+ | Event system | Rails Structured Event Reporter + Action Cable bridge |
93
+ | LLM integration | Anthropic API (Claude Sonnet 4) |
94
+ | Transport | Action Cable WebSocket (Solid Cable adapter) |
95
+ | Background jobs | Solid Queue |
96
+ | Interface | TUI via RatatuiRuby (WebSocket client) |
97
+ | Process management | Foreman |
80
98
  | Distribution | RubyGems (`gem install anima-core`) |
81
99
 
82
100
  ### Distribution Model
@@ -85,8 +103,16 @@ Anima is a Rails app distributed as a gem, following Unix philosophy: immutable
85
103
 
86
104
  ```bash
87
105
  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/
106
+ anima install # Create ~/.anima/, set up databases, start brain as systemd service
107
+ anima tui # Connect the terminal interface
108
+ ```
109
+
110
+ The installer creates a systemd user service that starts the brain automatically on login. Manage it with:
111
+
112
+ ```bash
113
+ systemctl --user status anima # Check brain status
114
+ systemctl --user restart anima # Restart brain
115
+ journalctl --user -u anima # View logs
90
116
  ```
91
117
 
92
118
  State directory (`~/.anima/`):
@@ -158,13 +184,17 @@ Five event types form the agent's nervous system:
158
184
  | `tool_call` | Tool invocation |
159
185
  | `tool_response` | Tool result |
160
186
 
187
+ Events flow through two channels:
188
+ 1. **In-process** — Rails Structured Event Reporter (local subscribers like Persister)
189
+ 2. **Over the wire** — Action Cable WebSocket (the ActionCableBridge subscriber forwards events to connected TUI clients)
190
+
161
191
  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
192
 
163
193
  ### Context as Viewport, Not Tape
164
194
 
165
195
  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
196
 
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.
197
+ Currently 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.
168
198
 
169
199
  ### Brain as Microservices on a Shared Event Bus
170
200
 
@@ -198,7 +228,7 @@ anima add anima-tools-shell
198
228
  anima add anima-feelings-frustration
199
229
  ```
200
230
 
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.
231
+ Tools provide MCP capabilities. Feelings are event subscribers that update hormonal state. Same mechanism, different namespace. Currently tools are built-in; plugin extraction comes later.
202
232
 
203
233
  ### Semantic Memory (Mneme)
204
234
 
@@ -301,36 +331,36 @@ This single example demonstrates every core principle:
301
331
 
302
332
  ## Status
303
333
 
304
- POC stage. Gem scaffold with CI and RubyGems publishing exists. Building toward a working conversational agent with event-driven architecture.
334
+ **Core agent complete.** The conversational agent works end-to-end: event-driven architecture, LLM integration with tool calling (bash, web), sliding viewport context assembly, persistent sessions, and client-server architecture with WebSocket transport and graceful reconnection.
305
335
 
306
- The hormonal system (Thymos, feelings, desires), semantic memory (Mneme), and soul matrix (Psyche) are designed but deferredPOC focuses on getting the core agent loop working first.
336
+ The hormonal system (Thymos, feelings, desires), semantic memory (Mneme), and soul matrix (Psyche) are designed but not yet implemented they're the next layer on top of the working agent.
307
337
 
308
338
  ## Development
309
339
 
310
340
  ```bash
311
341
  git clone https://github.com/hoblin/anima.git
312
342
  cd anima
313
- bundle install
314
- bundle exec rspec
343
+ bin/setup
315
344
  ```
316
345
 
317
- ### Running the TUI
346
+ ### Running Anima
318
347
 
319
- The TUI requires a background job worker for async token counting (used by the sliding viewport). Start both in separate terminals:
348
+ Start the brain server and TUI client in separate terminals:
320
349
 
321
350
  ```bash
322
- # Terminal 1: Start Solid Queue worker
323
- RAILS_ENV=development bundle exec rake solid_queue:start
351
+ # Terminal 1: Start brain (web server + background worker) on port 42135
352
+ bin/dev
324
353
 
325
- # Terminal 2: Launch the TUI
326
- bundle exec anima tui
354
+ # Terminal 2: Connect the TUI to the dev brain
355
+ bundle exec anima tui --host localhost:42135
327
356
  ```
328
357
 
329
- On first run, initialize the databases:
358
+ Development uses port **42135** so it doesn't conflict with the production brain (port 42134) running via systemd. On first run, `bin/dev` runs `db:prepare` automatically.
359
+
360
+ ### Running Tests
330
361
 
331
362
  ```bash
332
- RAILS_ENV=development bundle exec rails db:migrate
333
- RAILS_ENV=development bundle exec rails db:schema:load:queue
363
+ bundle exec rspec
334
364
  ```
335
365
 
336
366
  ## License
data/Rakefile CHANGED
@@ -1,13 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "bundler/gem_tasks"
4
- require "rspec/core/rake_task"
3
+ require_relative "config/application"
4
+ Rails.application.load_tasks
5
5
 
6
- RSpec::Core::RakeTask.new(:spec)
6
+ begin
7
+ require "bundler/gem_tasks"
8
+ rescue LoadError
9
+ # bundler not available in gem install context
10
+ end
7
11
 
8
- require "standard/rake"
12
+ begin
13
+ require "rspec/core/rake_task"
14
+ RSpec::Core::RakeTask.new(:spec)
15
+ rescue LoadError
16
+ # rspec not available in gem install context
17
+ end
9
18
 
10
- require_relative "config/application"
11
- Rails.application.load_tasks
19
+ begin
20
+ require "standard/rake"
21
+ rescue LoadError
22
+ # standard not available in gem install context
23
+ end
12
24
 
13
- task default: %i[spec standard]
25
+ task default: %i[spec standard] if Rake::Task.task_defined?(:spec) && Rake::Task.task_defined?(:standard)
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/anima/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "anima-core"
7
+ spec.version = Anima::VERSION
8
+ spec.authors = ["Yevhenii Hurin"]
9
+ spec.email = ["evgeny.gurin@gmail.com"]
10
+
11
+ spec.summary = "A personal AI agent with desires, personality, and personal growth"
12
+ spec.homepage = "https://github.com/hoblin/anima"
13
+ spec.license = "MIT"
14
+ spec.required_ruby_version = ">= 3.2.0"
15
+
16
+ spec.metadata["homepage_uri"] = spec.homepage
17
+ spec.metadata["source_code_uri"] = "https://github.com/hoblin/anima"
18
+ spec.metadata["changelog_uri"] = "https://github.com/hoblin/anima/blob/main/CHANGELOG.md"
19
+
20
+ # Specify which files should be added to the gem when it is released.
21
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
+ spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
23
+ ls.readlines("\x0", chomp: true).reject do |f|
24
+ f.start_with?(*%w[bin/console bin/dev bin/setup .gitignore .rspec spec/ .github/ .standard.yml thoughts/ CLAUDE.md])
25
+ end
26
+ end
27
+ spec.bindir = "exe"
28
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
+ spec.require_paths = ["lib"]
30
+
31
+ spec.add_dependency "foreman", "~> 0.88"
32
+ spec.add_dependency "httparty", "~> 0.24"
33
+ spec.add_dependency "puma", "~> 6.0"
34
+ spec.add_dependency "rails", "~> 8.1"
35
+ spec.add_dependency "ratatui_ruby", "~> 1.4"
36
+ spec.add_dependency "solid_cable", "~> 3.0"
37
+ spec.add_dependency "solid_queue", "~> 1.1"
38
+ spec.add_dependency "sqlite3", "~> 2.0"
39
+ spec.add_dependency "websocket-client-simple", "~> 0.8"
40
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApplicationCable
4
+ class Channel < ActionCable::Channel::Base
5
+ end
6
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ApplicationCable
4
+ class Connection < ActionCable::Connection::Base
5
+ end
6
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Streams events for a specific session to connected clients.
4
+ # Part of the Brain/TUI separation: the Brain broadcasts events through
5
+ # this channel, and any number of clients (TUI, web, API) can subscribe.
6
+ #
7
+ # On subscription, sends the session's chat history so the client can
8
+ # render previous messages without a separate API call.
9
+ #
10
+ # @example Client subscribes to a session
11
+ # App.cable.subscriptions.create({ channel: "SessionChannel", session_id: 42 })
12
+ class SessionChannel < ApplicationCable::Channel
13
+ DEFAULT_LIST_LIMIT = 10
14
+ MAX_LIST_LIMIT = 50
15
+
16
+ # Subscribes the client to the session-specific stream.
17
+ # Rejects the subscription if no valid session_id is provided.
18
+ # Transmits chat history to the subscribing client after confirmation.
19
+ #
20
+ # @param params [Hash] must include :session_id (positive integer)
21
+ def subscribed
22
+ @current_session_id = params[:session_id].to_i
23
+ if @current_session_id > 0
24
+ stream_from stream_name
25
+ transmit_history
26
+ else
27
+ reject
28
+ end
29
+ end
30
+
31
+ # Receives messages from clients and broadcasts them to all session subscribers.
32
+ #
33
+ # @param data [Hash] arbitrary message payload
34
+ def receive(data)
35
+ ActionCable.server.broadcast(stream_name, data)
36
+ end
37
+
38
+ # Processes user input: persists the message and enqueues LLM processing.
39
+ #
40
+ # @param data [Hash] must include "content" with the user's message text
41
+ def speak(data)
42
+ content = data["content"].to_s.strip
43
+ return if content.empty? || !Session.exists?(@current_session_id)
44
+
45
+ Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id))
46
+ AgentRequestJob.perform_later(@current_session_id)
47
+ end
48
+
49
+ # Returns recent sessions with metadata for session picker UI.
50
+ #
51
+ # @param data [Hash] optional "limit" (default 10, max 50)
52
+ def list_sessions(data)
53
+ limit = (data["limit"] || DEFAULT_LIST_LIMIT).to_i.clamp(1, MAX_LIST_LIMIT)
54
+ sessions = Session.recent(limit)
55
+ counts = Event.where(session_id: sessions.select(:id)).llm_messages.group(:session_id).count
56
+
57
+ result = sessions.map do |session|
58
+ {
59
+ id: session.id,
60
+ created_at: session.created_at.iso8601,
61
+ updated_at: session.updated_at.iso8601,
62
+ message_count: counts[session.id] || 0
63
+ }
64
+ end
65
+ transmit({"action" => "sessions_list", "sessions" => result})
66
+ end
67
+
68
+ # Creates a new session and switches the channel stream to it.
69
+ # The client receives a session_changed signal followed by (empty) history.
70
+ def create_session(_data)
71
+ session = Session.create!
72
+ switch_to_session(session.id)
73
+ end
74
+
75
+ # Switches the channel stream to an existing session.
76
+ # The client receives a session_changed signal followed by chat history.
77
+ #
78
+ # @param data [Hash] must include "session_id" (positive integer)
79
+ def switch_session(data)
80
+ target_id = data["session_id"].to_i
81
+ return transmit_error("Session not found") unless target_id > 0
82
+
83
+ switch_to_session(target_id)
84
+ rescue ActiveRecord::RecordNotFound
85
+ transmit_error("Session not found")
86
+ end
87
+
88
+ private
89
+
90
+ def stream_name
91
+ "session_#{@current_session_id}"
92
+ end
93
+
94
+ # Switches the channel to a different session: stops current stream,
95
+ # updates the session reference, starts the new stream, and sends
96
+ # a session_changed signal followed by chat history.
97
+ def switch_to_session(new_id)
98
+ stop_all_streams
99
+ @current_session_id = new_id
100
+ stream_from stream_name
101
+ session = Session.find(new_id)
102
+ transmit({
103
+ "action" => "session_changed",
104
+ "session_id" => new_id,
105
+ "message_count" => session.events.llm_messages.count
106
+ })
107
+ transmit_history
108
+ end
109
+
110
+ # Sends displayable events from the LLM's viewport to the subscribing
111
+ # client. The TUI shows exactly what the agent can see — no more, no less.
112
+ def transmit_history
113
+ session = Session.find_by(id: @current_session_id)
114
+ return unless session
115
+
116
+ session.viewport_events.each do |event|
117
+ next unless event.llm_message?
118
+
119
+ transmit(event.payload)
120
+ end
121
+ end
122
+
123
+ def transmit_error(message)
124
+ transmit({"action" => "error", "message" => message})
125
+ end
126
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Api
4
+ # REST endpoint for session management. The TUI client uses this to
5
+ # obtain a session ID before subscribing to the WebSocket channel.
6
+ class SessionsController < ApplicationController
7
+ # Returns the most recent session or creates one if none exist.
8
+ #
9
+ # GET /api/sessions/current
10
+ # @return [JSON] { id: Integer }
11
+ def current
12
+ session = Session.last || Session.create!
13
+ render json: {id: session.id}
14
+ end
15
+
16
+ # Creates a new conversation session.
17
+ #
18
+ # POST /api/sessions
19
+ # @return [JSON] { id: Integer }
20
+ def create
21
+ session = Session.create!
22
+ render json: {id: session.id}, status: :created
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationController < ActionController::API
4
+ 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
@@ -12,18 +12,15 @@ class Session < ApplicationRecord
12
12
 
13
13
  has_many :events, -> { order(:id) }, dependent: :destroy
14
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
- #
15
+ scope :recent, ->(limit = 10) { order(updated_at: :desc).limit(limit) }
16
+
17
+ # Returns the events currently visible in the LLM context window.
21
18
  # Walks events newest-first and includes them until the token budget
22
19
  # is exhausted. Events are full-size or excluded entirely.
23
20
  #
24
21
  # @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)
22
+ # @return [Array<Event>] chronologically ordered
23
+ def viewport_events(token_budget: DEFAULT_TOKEN_BUDGET)
27
24
  selected = []
28
25
  remaining = token_budget
29
26
 
@@ -35,7 +32,19 @@ class Session < ApplicationRecord
35
32
  remaining -= cost
36
33
  end
37
34
 
38
- assemble_messages(selected.reverse)
35
+ selected.reverse
36
+ end
37
+
38
+ # Builds the message array expected by the Anthropic Messages API.
39
+ # Includes user/agent messages and tool call/response events in
40
+ # Anthropic's wire format. Consecutive tool_call events are grouped
41
+ # into a single assistant message; consecutive tool_response events
42
+ # are grouped into a single user message with tool_result blocks.
43
+ #
44
+ # @param token_budget [Integer] maximum tokens to include (positive)
45
+ # @return [Array<Hash>] Anthropic Messages API format
46
+ def messages_for_llm(token_budget: DEFAULT_TOKEN_BUDGET)
47
+ assemble_messages(viewport_events(token_budget: token_budget))
39
48
  end
40
49
 
41
50
  private
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,9 @@ 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 "solid_cable"
10
12
  require "solid_queue"
11
13
 
12
14
  Bundler.require(*Rails.groups) if ENV.key?("BUNDLE_GEMFILE")
@@ -20,6 +22,8 @@ module Anima
20
22
  config.active_job.queue_adapter = :solid_queue
21
23
  config.solid_queue.connects_to = {database: {writing: :queue}}
22
24
 
25
+ config.action_cable.disable_request_forgery_protection = true
26
+
23
27
  anima_home = Pathname.new(File.expand_path("~/.anima"))
24
28
 
25
29
  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