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 +4 -4
- data/CHANGELOG.md +13 -3
- data/Gemfile +17 -0
- data/Procfile +2 -0
- data/Procfile.dev +2 -0
- data/README.md +56 -26
- data/Rakefile +19 -7
- data/anima-core.gemspec +40 -0
- data/app/channels/application_cable/channel.rb +6 -0
- data/app/channels/application_cable/connection.rb +6 -0
- data/app/channels/session_channel.rb +126 -0
- data/app/controllers/api/sessions_controller.rb +25 -0
- data/app/controllers/application_controller.rb +4 -0
- data/app/jobs/agent_request_job.rb +59 -0
- data/app/jobs/count_event_tokens_job.rb +1 -1
- data/app/models/session.rb +18 -9
- data/bin/jobs +6 -0
- data/bin/rails +6 -0
- data/bin/rake +6 -0
- data/config/application.rb +4 -0
- data/config/cable.yml +14 -0
- data/config/database.yml +12 -0
- data/config/initializers/event_subscribers.rb +11 -0
- data/config/puma.rb +13 -0
- data/config/routes.rb +8 -0
- data/config.ru +5 -0
- data/db/cable_schema.rb +11 -0
- data/lib/agent_loop.rb +97 -0
- data/lib/anima/cli.rb +64 -9
- data/lib/anima/installer.rb +4 -3
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -0
- data/lib/events/subscribers/action_cable_bridge.rb +35 -0
- data/lib/events/subscribers/persister.rb +14 -4
- data/lib/providers/anthropic.rb +11 -2
- data/lib/tui/app.rb +71 -13
- data/lib/tui/cable_client.rb +377 -0
- data/lib/tui/message_store.rb +49 -0
- data/lib/tui/screens/chat.rb +179 -68
- metadata +80 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fcb9e1d40357cd0eabdc5fffa01f8727b449a5b85e6f7b7dbe9033fae461bec9
|
|
4
|
+
data.tar.gz: d785a36f13e3a3e698b80dd123544ca6dbee535372b92776a5b7993e4125baa1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
data/Procfile.dev
ADDED
data/README.md
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
# Anima
|
|
1
|
+
# Anima
|
|
2
2
|
|
|
3
|
-
**A
|
|
3
|
+
**A personal AI agent that actually wants things.**
|
|
4
4
|
|
|
5
|
-
|
|
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
|
|
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,
|
|
77
|
-
| Event system | Rails Structured Event Reporter |
|
|
78
|
-
| LLM integration |
|
|
79
|
-
|
|
|
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
|
|
89
|
-
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
314
|
-
bundle exec rspec
|
|
343
|
+
bin/setup
|
|
315
344
|
```
|
|
316
345
|
|
|
317
|
-
### Running
|
|
346
|
+
### Running Anima
|
|
318
347
|
|
|
319
|
-
|
|
348
|
+
Start the brain server and TUI client in separate terminals:
|
|
320
349
|
|
|
321
350
|
```bash
|
|
322
|
-
# Terminal 1: Start
|
|
323
|
-
|
|
351
|
+
# Terminal 1: Start brain (web server + background worker) on port 42135
|
|
352
|
+
bin/dev
|
|
324
353
|
|
|
325
|
-
# Terminal 2:
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
|
|
3
|
+
require_relative "config/application"
|
|
4
|
+
Rails.application.load_tasks
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
begin
|
|
7
|
+
require "bundler/gem_tasks"
|
|
8
|
+
rescue LoadError
|
|
9
|
+
# bundler not available in gem install context
|
|
10
|
+
end
|
|
7
11
|
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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)
|
data/anima-core.gemspec
ADDED
|
@@ -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,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,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: :
|
|
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/session.rb
CHANGED
|
@@ -12,18 +12,15 @@ class Session < ApplicationRecord
|
|
|
12
12
|
|
|
13
13
|
has_many :events, -> { order(:id) }, dependent: :destroy
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
#
|
|
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<
|
|
26
|
-
def
|
|
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
|
-
|
|
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
data/bin/rails
ADDED
data/bin/rake
ADDED
data/config/application.rb
CHANGED
|
@@ -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]
|