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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +18 -0
  3. data/CHANGELOG.md +36 -0
  4. data/Gemfile +17 -0
  5. data/Procfile +2 -0
  6. data/Procfile.dev +2 -0
  7. data/README.md +167 -22
  8. data/Rakefile +20 -5
  9. data/anima-core.gemspec +40 -0
  10. data/app/channels/application_cable/channel.rb +6 -0
  11. data/app/channels/application_cable/connection.rb +6 -0
  12. data/app/channels/session_channel.rb +126 -0
  13. data/app/controllers/api/sessions_controller.rb +25 -0
  14. data/app/controllers/application_controller.rb +4 -0
  15. data/app/jobs/agent_request_job.rb +59 -0
  16. data/app/jobs/application_job.rb +4 -0
  17. data/app/jobs/count_event_tokens_job.rb +28 -0
  18. data/app/models/application_record.rb +5 -0
  19. data/app/models/event.rb +64 -0
  20. data/app/models/session.rb +114 -0
  21. data/bin/jobs +6 -0
  22. data/bin/rails +6 -0
  23. data/bin/rake +6 -0
  24. data/config/application.rb +35 -0
  25. data/config/boot.rb +8 -0
  26. data/config/cable.yml +14 -0
  27. data/config/database.yml +45 -0
  28. data/config/environment.rb +5 -0
  29. data/config/environments/development.rb +8 -0
  30. data/config/environments/production.rb +8 -0
  31. data/config/environments/test.rb +9 -0
  32. data/config/initializers/event_subscribers.rb +11 -0
  33. data/config/initializers/inflections.rb +9 -0
  34. data/config/puma.rb +13 -0
  35. data/config/queue.yml +18 -0
  36. data/config/recurring.yml +15 -0
  37. data/config/routes.rb +12 -0
  38. data/config.ru +5 -0
  39. data/db/cable_schema.rb +11 -0
  40. data/db/migrate/.keep +0 -0
  41. data/db/migrate/20260308124202_create_sessions.rb +9 -0
  42. data/db/migrate/20260308124203_create_events.rb +18 -0
  43. data/db/migrate/20260308130000_add_event_indexes.rb +9 -0
  44. data/db/migrate/20260308140000_remove_position_from_events.rb +8 -0
  45. data/db/migrate/20260308150000_add_token_count_to_events.rb +7 -0
  46. data/db/migrate/20260308160000_add_tool_use_id_to_events.rb +8 -0
  47. data/db/queue_schema.rb +141 -0
  48. data/db/seeds.rb +1 -0
  49. data/exe/anima +6 -0
  50. data/lib/agent_loop.rb +97 -0
  51. data/lib/anima/cli.rb +110 -0
  52. data/lib/anima/installer.rb +119 -0
  53. data/lib/anima/version.rb +1 -1
  54. data/lib/anima.rb +5 -0
  55. data/lib/events/agent_message.rb +11 -0
  56. data/lib/events/base.rb +38 -0
  57. data/lib/events/bus.rb +39 -0
  58. data/lib/events/subscriber.rb +26 -0
  59. data/lib/events/subscribers/action_cable_bridge.rb +35 -0
  60. data/lib/events/subscribers/message_collector.rb +64 -0
  61. data/lib/events/subscribers/persister.rb +56 -0
  62. data/lib/events/system_message.rb +11 -0
  63. data/lib/events/tool_call.rb +29 -0
  64. data/lib/events/tool_response.rb +33 -0
  65. data/lib/events/user_message.rb +11 -0
  66. data/lib/llm/client.rb +161 -0
  67. data/lib/providers/anthropic.rb +173 -0
  68. data/lib/shell_session.rb +333 -0
  69. data/lib/tools/base.rb +58 -0
  70. data/lib/tools/bash.rb +53 -0
  71. data/lib/tools/registry.rb +60 -0
  72. data/lib/tools/web_get.rb +62 -0
  73. data/lib/tui/app.rb +239 -0
  74. data/lib/tui/cable_client.rb +377 -0
  75. data/lib/tui/message_store.rb +49 -0
  76. data/lib/tui/screens/anthropic.rb +25 -0
  77. data/lib/tui/screens/chat.rb +321 -0
  78. data/lib/tui/screens/settings.rb +52 -0
  79. metadata +203 -6
  80. 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: fcb9e1d40357cd0eabdc5fffa01f8727b449a5b85e6f7b7dbe9033fae461bec9
4
+ data.tar.gz: d785a36f13e3a3e698b80dd123544ca6dbee535372b92776a5b7993e4125baa1
5
5
  SHA512:
6
- metadata.gz: 2e5b40e05fdd0a193adf362d4eda84d83ddfe51f379b8ebb3cc17070af733371526632946ecd577756815d10bd410e919a3013a942821ccf1f4c9fea966bfbe8
7
- data.tar.gz: b28b6f7e60478e06bedeb8a87a4ce682dd65a9111007626565af515518f614e682b18bd26adb59ba6aa3d9dd1bc5c468c605229c880b4da0ad3ccdd96257e76a
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
@@ -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
- 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 Framework (Ruby, Rage-based)
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 (JSON/SQLite). Current hormone levels with decay functions. No intelligence, just state that changes over time.
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 [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.
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
- Single-process architecture: web server, background hormone ticks, WebSocket monitoring — all in one process, no Redis, no external workers.
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
- 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.
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
- Idea stage early design. Architecture research underway (OpenClaw agent loop documented).
211
- First practical hormone (frustration) designed, ready for prototyping.
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
- ## Next Steps
366
+ ## License
214
367
 
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
368
+ MIT License. See [LICENSE.txt](LICENSE.txt).
data/Rakefile CHANGED
@@ -1,10 +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
- task default: %i[spec standard]
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)
@@ -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
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationJob < ActiveJob::Base
4
+ end