anima-core 0.0.1 → 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/.reek.yml +18 -0
- data/CHANGELOG.md +36 -0
- data/Gemfile +17 -0
- data/Procfile +2 -0
- data/Procfile.dev +2 -0
- data/README.md +167 -22
- data/Rakefile +20 -5
- 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/application_job.rb +4 -0
- data/app/jobs/count_event_tokens_job.rb +28 -0
- data/app/models/application_record.rb +5 -0
- data/app/models/event.rb +64 -0
- data/app/models/session.rb +114 -0
- data/bin/jobs +6 -0
- data/bin/rails +6 -0
- data/bin/rake +6 -0
- data/config/application.rb +35 -0
- data/config/boot.rb +8 -0
- data/config/cable.yml +14 -0
- data/config/database.yml +45 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +8 -0
- data/config/environments/production.rb +8 -0
- data/config/environments/test.rb +9 -0
- data/config/initializers/event_subscribers.rb +11 -0
- data/config/initializers/inflections.rb +9 -0
- data/config/puma.rb +13 -0
- data/config/queue.yml +18 -0
- data/config/recurring.yml +15 -0
- data/config/routes.rb +12 -0
- data/config.ru +5 -0
- data/db/cable_schema.rb +11 -0
- data/db/migrate/.keep +0 -0
- data/db/migrate/20260308124202_create_sessions.rb +9 -0
- data/db/migrate/20260308124203_create_events.rb +18 -0
- data/db/migrate/20260308130000_add_event_indexes.rb +9 -0
- data/db/migrate/20260308140000_remove_position_from_events.rb +8 -0
- data/db/migrate/20260308150000_add_token_count_to_events.rb +7 -0
- data/db/migrate/20260308160000_add_tool_use_id_to_events.rb +8 -0
- data/db/queue_schema.rb +141 -0
- data/db/seeds.rb +1 -0
- data/exe/anima +6 -0
- data/lib/agent_loop.rb +97 -0
- data/lib/anima/cli.rb +110 -0
- data/lib/anima/installer.rb +119 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +5 -0
- data/lib/events/agent_message.rb +11 -0
- data/lib/events/base.rb +38 -0
- data/lib/events/bus.rb +39 -0
- data/lib/events/subscriber.rb +26 -0
- data/lib/events/subscribers/action_cable_bridge.rb +35 -0
- data/lib/events/subscribers/message_collector.rb +64 -0
- data/lib/events/subscribers/persister.rb +56 -0
- data/lib/events/system_message.rb +11 -0
- data/lib/events/tool_call.rb +29 -0
- data/lib/events/tool_response.rb +33 -0
- data/lib/events/user_message.rb +11 -0
- data/lib/llm/client.rb +161 -0
- data/lib/providers/anthropic.rb +173 -0
- data/lib/shell_session.rb +333 -0
- data/lib/tools/base.rb +58 -0
- data/lib/tools/bash.rb +53 -0
- data/lib/tools/registry.rb +60 -0
- data/lib/tools/web_get.rb +62 -0
- data/lib/tui/app.rb +239 -0
- data/lib/tui/cable_client.rb +377 -0
- data/lib/tui/message_store.rb +49 -0
- data/lib/tui/screens/anthropic.rb +25 -0
- data/lib/tui/screens/chat.rb +321 -0
- data/lib/tui/screens/settings.rb +52 -0
- metadata +203 -6
- data/BRAINSTORM.md +0 -466
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/.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,41 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.0] - 2026-03-10
|
|
4
|
+
|
|
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
|
|
14
|
+
- Session and event persistence to SQLite — conversations survive TUI restart
|
|
15
|
+
- `Session` model — owns an ordered event stream
|
|
16
|
+
- `Event` model — polymorphic type, JSON payload, auto-incrementing position
|
|
17
|
+
- `Events::Subscribers::Persister` — writes all events to SQLite as they flow through the bus
|
|
18
|
+
- TUI resumes last session on startup, `Ctrl+a > n` creates a new session
|
|
19
|
+
- Event system using Rails Structured Event Reporter (`Rails.event`)
|
|
20
|
+
- Five event types: `system_message`, `user_message`, `agent_message`, `tool_call`, `tool_response`
|
|
21
|
+
- `Events::Bus` — thin wrapper around `Rails.event` for emitting and subscribing to Anima events
|
|
22
|
+
- `Events::Subscribers::MessageCollector` — in-memory subscriber that collects displayable messages
|
|
23
|
+
- Chat screen refactored from raw array to event-driven architecture
|
|
24
|
+
- TUI chat screen with LLM integration — in-memory message array, threaded API calls
|
|
25
|
+
- Chat input with character validation, backspace, Enter to submit
|
|
26
|
+
- Loading indicator — "Thinking" status bar mode, grayed-out input during LLM calls
|
|
27
|
+
- New session command (`Ctrl+a > n`) clears conversation
|
|
28
|
+
- Error handling — API failures displayed inline as chat messages
|
|
29
|
+
- Anthropic API subscription token authentication
|
|
30
|
+
- LLM client (raw HTTP to Anthropic API)
|
|
31
|
+
- TUI scaffold with RatatuiRuby — tmux-style `Ctrl+a` command mode, sidebar, status bar
|
|
32
|
+
- Headless Rails 8.1 app (API-only, no views/assets)
|
|
33
|
+
- `anima install` command — creates ~/.anima/ tree, per-environment credentials, systemd user service
|
|
34
|
+
- `anima start` command — runs db:prepare and boots Rails via Foreman
|
|
35
|
+
- Systemd user service — auto-enables and starts brain on `anima install`
|
|
36
|
+
- SQLite databases, logs, tmp, and credentials stored in ~/.anima/
|
|
37
|
+
- Environment validation (development, test, production)
|
|
38
|
+
|
|
3
39
|
## [0.0.1] - 2026-03-06
|
|
4
40
|
|
|
5
41
|
- Initial gem scaffold with CI and RubyGems publishing
|
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
|
-
Powered by [Rage](https://rage-rb.dev/).
|
|
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,26 +60,141 @@ 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
|
+
|
|
86
|
+
### Tech Stack
|
|
87
|
+
|
|
88
|
+
| Component | Technology |
|
|
89
|
+
|-----------|-----------|
|
|
90
|
+
| Framework | Rails 8.1 (headless — no web views, no asset pipeline) |
|
|
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 |
|
|
98
|
+
| Distribution | RubyGems (`gem install anima-core`) |
|
|
99
|
+
|
|
100
|
+
### Distribution Model
|
|
101
|
+
|
|
102
|
+
Anima is a Rails app distributed as a gem, following Unix philosophy: immutable program separate from mutable data.
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
gem install anima-core # Install the Rails app as a gem
|
|
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
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
State directory (`~/.anima/`):
|
|
119
|
+
```
|
|
120
|
+
~/.anima/
|
|
121
|
+
├── db/ # SQLite databases (production, development, test)
|
|
122
|
+
├── config/
|
|
123
|
+
│ ├── credentials/ # Rails encrypted credentials per environment
|
|
124
|
+
│ └── anima.yml # User configuration
|
|
125
|
+
├── log/
|
|
126
|
+
└── tmp/
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Updates: `gem update anima-core` — next launch runs pending migrations automatically.
|
|
130
|
+
|
|
131
|
+
### Authentication Setup
|
|
132
|
+
|
|
133
|
+
Anima uses your Claude Pro/Max subscription for API access. You need a setup-token from Claude Code CLI.
|
|
134
|
+
|
|
135
|
+
**1. Get a setup-token:**
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
claude setup-token
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
This requires [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and a Claude Pro or Max subscription.
|
|
142
|
+
|
|
143
|
+
**2. Store the token in Anima credentials:**
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
cd $(gem contents anima-core | head -1 | xargs dirname | xargs dirname)
|
|
147
|
+
bin/rails credentials:edit
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
Add your token:
|
|
151
|
+
|
|
152
|
+
```yaml
|
|
153
|
+
anthropic:
|
|
154
|
+
subscription_token: sk-ant-oat01-YOUR_TOKEN_HERE
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**3. Verify the token works:**
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
bin/rails runner "Providers::Anthropic.validate!"
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
If the token expires or is revoked, repeat steps 1-2 with a new token.
|
|
164
|
+
|
|
71
165
|
### Three Layers (mirroring biology)
|
|
72
166
|
|
|
73
167
|
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
168
|
|
|
75
|
-
2. **Homeostasis** — persistent state (
|
|
169
|
+
2. **Homeostasis** — persistent state (SQLite). Current hormone levels with decay functions. No intelligence, just state that changes over time.
|
|
76
170
|
|
|
77
171
|
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
172
|
|
|
79
173
|
### Event-Driven Design
|
|
80
174
|
|
|
81
|
-
Built on
|
|
175
|
+
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.
|
|
176
|
+
|
|
177
|
+
Five event types form the agent's nervous system:
|
|
178
|
+
|
|
179
|
+
| Event | Purpose |
|
|
180
|
+
|-------|---------|
|
|
181
|
+
| `system_message` | Internal notifications |
|
|
182
|
+
| `user_message` | User input |
|
|
183
|
+
| `agent_message` | LLM response |
|
|
184
|
+
| `tool_call` | Tool invocation |
|
|
185
|
+
| `tool_response` | Tool result |
|
|
82
186
|
|
|
83
|
-
|
|
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
|
+
|
|
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.
|
|
192
|
+
|
|
193
|
+
### Context as Viewport, Not Tape
|
|
194
|
+
|
|
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.
|
|
196
|
+
|
|
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.
|
|
84
198
|
|
|
85
199
|
### Brain as Microservices on a Shared Event Bus
|
|
86
200
|
|
|
@@ -95,7 +209,7 @@ Event: "tool_call_failed"
|
|
|
95
209
|
├── Mneme subscriber: log failure context for future recall
|
|
96
210
|
└── Psyche subscriber: update coefficient (this agent handles errors calmly → low frustration_gain)
|
|
97
211
|
|
|
98
|
-
Event: "user_sent_message"
|
|
212
|
+
Event: "user_sent_message"
|
|
99
213
|
│
|
|
100
214
|
├── Thymos subscriber: oxytocin += 5 (bonding signal)
|
|
101
215
|
├── Thymos subscriber: dopamine += 3 (engagement signal)
|
|
@@ -104,7 +218,17 @@ Event: "user_sent_message"
|
|
|
104
218
|
|
|
105
219
|
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
220
|
|
|
107
|
-
|
|
221
|
+
### Plugin Architecture
|
|
222
|
+
|
|
223
|
+
Both tools and feelings are distributed as gems on the event bus:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
anima add anima-tools-filesystem
|
|
227
|
+
anima add anima-tools-shell
|
|
228
|
+
anima add anima-feelings-frustration
|
|
229
|
+
```
|
|
230
|
+
|
|
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.
|
|
108
232
|
|
|
109
233
|
### Semantic Memory (Mneme)
|
|
110
234
|
|
|
@@ -207,17 +331,38 @@ This single example demonstrates every core principle:
|
|
|
207
331
|
|
|
208
332
|
## Status
|
|
209
333
|
|
|
210
|
-
|
|
211
|
-
|
|
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.
|
|
335
|
+
|
|
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.
|
|
337
|
+
|
|
338
|
+
## Development
|
|
339
|
+
|
|
340
|
+
```bash
|
|
341
|
+
git clone https://github.com/hoblin/anima.git
|
|
342
|
+
cd anima
|
|
343
|
+
bin/setup
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Running Anima
|
|
347
|
+
|
|
348
|
+
Start the brain server and TUI client in separate terminals:
|
|
349
|
+
|
|
350
|
+
```bash
|
|
351
|
+
# Terminal 1: Start brain (web server + background worker) on port 42135
|
|
352
|
+
bin/dev
|
|
353
|
+
|
|
354
|
+
# Terminal 2: Connect the TUI to the dev brain
|
|
355
|
+
bundle exec anima tui --host localhost:42135
|
|
356
|
+
```
|
|
357
|
+
|
|
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
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
bundle exec rspec
|
|
364
|
+
```
|
|
212
365
|
|
|
213
|
-
##
|
|
366
|
+
## License
|
|
214
367
|
|
|
215
|
-
|
|
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
|
|
368
|
+
MIT License. See [LICENSE.txt](LICENSE.txt).
|
data/Rakefile
CHANGED
|
@@ -1,10 +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
|
-
|
|
19
|
+
begin
|
|
20
|
+
require "standard/rake"
|
|
21
|
+
rescue LoadError
|
|
22
|
+
# standard not available in gem install context
|
|
23
|
+
end
|
|
24
|
+
|
|
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
|