anima-core 0.1.0 → 0.2.1
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 +32 -3
- data/Gemfile +17 -0
- data/Procfile +2 -0
- data/Procfile.dev +2 -0
- data/README.md +68 -26
- data/Rakefile +19 -7
- data/anima-core.gemspec +41 -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 +218 -0
- data/app/controllers/api/sessions_controller.rb +25 -0
- data/app/controllers/application_controller.rb +4 -0
- data/app/decorators/agent_message_decorator.rb +24 -0
- data/app/decorators/application_decorator.rb +6 -0
- data/app/decorators/event_decorator.rb +173 -0
- data/app/decorators/system_message_decorator.rb +21 -0
- data/app/decorators/tool_call_decorator.rb +48 -0
- data/app/decorators/tool_response_decorator.rb +37 -0
- data/app/decorators/user_message_decorator.rb +24 -0
- data/app/jobs/agent_request_job.rb +59 -0
- data/app/jobs/count_event_tokens_job.rb +1 -1
- data/app/models/event.rb +17 -0
- data/app/models/session.rb +40 -19
- data/bin/jobs +6 -0
- data/bin/rails +6 -0
- data/bin/rake +6 -0
- data/config/application.rb +5 -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 +23 -0
- data/db/migrate/20260312170000_add_view_mode_to_sessions.rb +7 -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 +59 -0
- data/lib/events/subscribers/persister.rb +14 -4
- data/lib/providers/anthropic.rb +11 -2
- data/lib/tui/app.rb +209 -45
- data/lib/tui/cable_client.rb +387 -0
- data/lib/tui/input_buffer.rb +181 -0
- data/lib/tui/message_store.rb +122 -0
- data/lib/tui/screens/chat.rb +567 -88
- metadata +103 -5
- data/lib/tui/screens/anthropic.rb +0 -25
- data/lib/tui/screens/settings.rb +0 -52
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9517757f2c4fdb8b19d204a154d3badd3c3b8fb456dffaf03236b2d7c065378d
|
|
4
|
+
data.tar.gz: a0d26e0b27fd1d2df0c46c3a4bbad822ac3517d1d02b41a6bdd49195de64e71f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d150e5f97a3a2055c66cfaa773c7bd1dd33354b869fe38d6fa25b2b47352c76cec0c222f44aa4eb7e7e65b61098b7531e61522a01e6dd1d722ee9224c7759aea
|
|
7
|
+
data.tar.gz: 0f9ef56323a4598ed8dc3dca79e6ec9cf1e63ea9dff8751612cd32691aa0968e629144b4f7c47150cd9c9861f3d74e323c852433e1aa066732bb10fc8db235fc
|
data/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,40 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.2.1] - 2026-03-13
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- TUI view mode switching via `Ctrl+a → v` — cycle between Basic, Verbose, and Debug (#75)
|
|
7
|
+
- Draper EventDecorator hierarchy — structured data decorators for all event types (#74)
|
|
8
|
+
- Decorators return structured hashes (not strings) for transport-layer filtering (#86)
|
|
9
|
+
- Basic mode tool call counter — inline `🔧 Tools: X/Y ✓` aggregation (#73)
|
|
10
|
+
- Verbose view mode rendering — timestamps, tool call previews, system messages (#76)
|
|
11
|
+
- Tool call previews: bash `$ command`, web_get `GET url`, generic JSON fallback
|
|
12
|
+
- Tool response display: truncated to 3 lines, `↩` success / `❌` failure indicators
|
|
13
|
+
- Debug view mode — token counts per message, full tool args/responses, tool use IDs (#77)
|
|
14
|
+
- Estimated token indicator (`~` prefix) for events not yet counted by background job
|
|
15
|
+
- View mode persisted on Session model — survives TUI disconnect/reconnect
|
|
16
|
+
- Mode changes broadcast to all connected clients with re-decorated viewport
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- Newlines in LLM responses collapsed into single line in rendered view modes
|
|
20
|
+
- Loading state stuck after view mode switch — input blocked with "Thinking..."
|
|
21
|
+
|
|
22
|
+
## [0.2.0] - 2026-03-10
|
|
23
|
+
|
|
3
24
|
### Added
|
|
25
|
+
- Client-server architecture — Brain (Rails + Puma) runs as persistent service, TUI connects via WebSocket
|
|
26
|
+
- Action Cable infrastructure with Solid Cable adapter for Brain/TUI WebSocket communication
|
|
27
|
+
- `SessionChannel` — WebSocket channel for session management, message relay, and session switching
|
|
28
|
+
- Graceful TUI reconnection with exponential backoff (up to 10 attempts, max 30s delay)
|
|
29
|
+
- `AgentRequestJob` — background job for LLM agent loops with retry logic for transient failures (network errors, rate limits, server errors)
|
|
30
|
+
- Provider error hierarchy — `TransientError`, `RateLimitError`, `ServerError` for retry classification; `AuthenticationError` for immediate discard
|
|
31
|
+
- `AgentLoop#run` — retry-safe entry point for job callers; lets errors propagate for external retry handling
|
|
32
|
+
- `AgentLoop` service — decouples LLM orchestration from TUI; callable from background jobs, Action Cable channels, or TUI directly
|
|
4
33
|
- Session and event persistence to SQLite — conversations survive TUI restart
|
|
5
34
|
- `Session` model — owns an ordered event stream
|
|
6
35
|
- `Event` model — polymorphic type, JSON payload, auto-incrementing position
|
|
7
36
|
- `Events::Subscribers::Persister` — writes all events to SQLite as they flow through the bus
|
|
8
37
|
- TUI resumes last session on startup, `Ctrl+a > n` creates a new session
|
|
9
|
-
- `anima tui` now runs pending migrations automatically on launch
|
|
10
38
|
- Event system using Rails Structured Event Reporter (`Rails.event`)
|
|
11
39
|
- Five event types: `system_message`, `user_message`, `agent_message`, `tool_call`, `tool_response`
|
|
12
40
|
- `Events::Bus` — thin wrapper around `Rails.event` for emitting and subscribing to Anima events
|
|
@@ -20,9 +48,10 @@
|
|
|
20
48
|
- Anthropic API subscription token authentication
|
|
21
49
|
- LLM client (raw HTTP to Anthropic API)
|
|
22
50
|
- TUI scaffold with RatatuiRuby — tmux-style `Ctrl+a` command mode, sidebar, status bar
|
|
23
|
-
- Headless Rails 8.1 app (API-only, no views/assets
|
|
51
|
+
- Headless Rails 8.1 app (API-only, no views/assets)
|
|
24
52
|
- `anima install` command — creates ~/.anima/ tree, per-environment credentials, systemd user service
|
|
25
|
-
- `anima start` command — runs db:prepare and boots Rails
|
|
53
|
+
- `anima start` command — runs db:prepare and boots Rails via Foreman
|
|
54
|
+
- Systemd user service — auto-enables and starts brain on `anima install`
|
|
26
55
|
- SQLite databases, logs, tmp, and credentials stored in ~/.anima/
|
|
27
56
|
- Environment validation (development, test, production)
|
|
28
57
|
|
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,29 @@ 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 associative recall from Mneme.
|
|
198
|
+
|
|
199
|
+
### TUI View Modes
|
|
200
|
+
|
|
201
|
+
Three switchable view modes let you control how much detail the TUI shows. Cycle with `Ctrl+a → v`:
|
|
202
|
+
|
|
203
|
+
| Mode | What you see |
|
|
204
|
+
|------|-------------|
|
|
205
|
+
| **Basic** (default) | User + assistant messages. Tool calls are hidden but summarized as an inline counter: `🔧 Tools: 2/2 ✓` |
|
|
206
|
+
| **Verbose** | Everything in Basic, plus timestamps `[HH:MM:SS]`, tool call previews (`🔧 bash` / `$ command` / `↩ response`), and system messages |
|
|
207
|
+
| **Debug** | Full X-ray view — timestamps, token counts per message (`[14 tok]`), full tool call args, full tool responses, tool use IDs |
|
|
208
|
+
|
|
209
|
+
View modes are implemented via Draper decorators that operate at the transport layer. Each event type has a dedicated decorator (`UserMessageDecorator`, `ToolCallDecorator`, etc.) that returns structured data — the TUI renders it. Mode is stored on the `Session` model server-side, so it persists across reconnections.
|
|
168
210
|
|
|
169
211
|
### Brain as Microservices on a Shared Event Bus
|
|
170
212
|
|
|
@@ -198,7 +240,7 @@ anima add anima-tools-shell
|
|
|
198
240
|
anima add anima-feelings-frustration
|
|
199
241
|
```
|
|
200
242
|
|
|
201
|
-
Tools provide MCP capabilities. Feelings are event subscribers that update hormonal state. Same mechanism, different namespace.
|
|
243
|
+
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
244
|
|
|
203
245
|
### Semantic Memory (Mneme)
|
|
204
246
|
|
|
@@ -301,36 +343,36 @@ This single example demonstrates every core principle:
|
|
|
301
343
|
|
|
302
344
|
## Status
|
|
303
345
|
|
|
304
|
-
|
|
346
|
+
**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, client-server architecture with WebSocket transport, graceful reconnection, and three TUI view modes (Basic/Verbose/Debug) via Draper decorators.
|
|
305
347
|
|
|
306
|
-
The hormonal system (Thymos, feelings, desires), semantic memory (Mneme), and soul matrix (Psyche) are designed but
|
|
348
|
+
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
349
|
|
|
308
350
|
## Development
|
|
309
351
|
|
|
310
352
|
```bash
|
|
311
353
|
git clone https://github.com/hoblin/anima.git
|
|
312
354
|
cd anima
|
|
313
|
-
|
|
314
|
-
bundle exec rspec
|
|
355
|
+
bin/setup
|
|
315
356
|
```
|
|
316
357
|
|
|
317
|
-
### Running
|
|
358
|
+
### Running Anima
|
|
318
359
|
|
|
319
|
-
|
|
360
|
+
Start the brain server and TUI client in separate terminals:
|
|
320
361
|
|
|
321
362
|
```bash
|
|
322
|
-
# Terminal 1: Start
|
|
323
|
-
|
|
363
|
+
# Terminal 1: Start brain (web server + background worker) on port 42135
|
|
364
|
+
bin/dev
|
|
324
365
|
|
|
325
|
-
# Terminal 2:
|
|
326
|
-
bundle exec anima tui
|
|
366
|
+
# Terminal 2: Connect the TUI to the dev brain
|
|
367
|
+
bundle exec anima tui --host localhost:42135
|
|
327
368
|
```
|
|
328
369
|
|
|
329
|
-
On first run,
|
|
370
|
+
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.
|
|
371
|
+
|
|
372
|
+
### Running Tests
|
|
330
373
|
|
|
331
374
|
```bash
|
|
332
|
-
|
|
333
|
-
RAILS_ENV=development bundle exec rails db:schema:load:queue
|
|
375
|
+
bundle exec rspec
|
|
334
376
|
```
|
|
335
377
|
|
|
336
378
|
## 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,41 @@
|
|
|
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 "draper", "~> 4.0"
|
|
32
|
+
spec.add_dependency "foreman", "~> 0.88"
|
|
33
|
+
spec.add_dependency "httparty", "~> 0.24"
|
|
34
|
+
spec.add_dependency "puma", "~> 6.0"
|
|
35
|
+
spec.add_dependency "rails", "~> 8.1"
|
|
36
|
+
spec.add_dependency "ratatui_ruby", "~> 1.4"
|
|
37
|
+
spec.add_dependency "solid_cable", "~> 3.0"
|
|
38
|
+
spec.add_dependency "solid_queue", "~> 1.1"
|
|
39
|
+
spec.add_dependency "sqlite3", "~> 2.0"
|
|
40
|
+
spec.add_dependency "websocket-client-simple", "~> 0.8"
|
|
41
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
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 the current view_mode and chat history to the subscribing client.
|
|
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_view_mode
|
|
26
|
+
transmit_history
|
|
27
|
+
else
|
|
28
|
+
reject
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Receives messages from clients and broadcasts them to all session subscribers.
|
|
33
|
+
#
|
|
34
|
+
# @param data [Hash] arbitrary message payload
|
|
35
|
+
def receive(data)
|
|
36
|
+
ActionCable.server.broadcast(stream_name, data)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Processes user input: persists the message and enqueues LLM processing.
|
|
40
|
+
#
|
|
41
|
+
# @param data [Hash] must include "content" with the user's message text
|
|
42
|
+
def speak(data)
|
|
43
|
+
content = data["content"].to_s.strip
|
|
44
|
+
return if content.empty? || !Session.exists?(@current_session_id)
|
|
45
|
+
|
|
46
|
+
Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id))
|
|
47
|
+
AgentRequestJob.perform_later(@current_session_id)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns recent sessions with metadata for session picker UI.
|
|
51
|
+
#
|
|
52
|
+
# @param data [Hash] optional "limit" (default 10, max 50)
|
|
53
|
+
def list_sessions(data)
|
|
54
|
+
limit = (data["limit"] || DEFAULT_LIST_LIMIT).to_i.clamp(1, MAX_LIST_LIMIT)
|
|
55
|
+
sessions = Session.recent(limit)
|
|
56
|
+
counts = Event.where(session_id: sessions.select(:id)).llm_messages.group(:session_id).count
|
|
57
|
+
|
|
58
|
+
result = sessions.map do |session|
|
|
59
|
+
{
|
|
60
|
+
id: session.id,
|
|
61
|
+
created_at: session.created_at.iso8601,
|
|
62
|
+
updated_at: session.updated_at.iso8601,
|
|
63
|
+
message_count: counts[session.id] || 0
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
transmit({"action" => "sessions_list", "sessions" => result})
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Creates a new session and switches the channel stream to it.
|
|
70
|
+
# The client receives a session_changed signal followed by (empty) history.
|
|
71
|
+
def create_session(_data)
|
|
72
|
+
session = Session.create!
|
|
73
|
+
switch_to_session(session.id)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Switches the channel stream to an existing session.
|
|
77
|
+
# The client receives a session_changed signal followed by chat history.
|
|
78
|
+
#
|
|
79
|
+
# @param data [Hash] must include "session_id" (positive integer)
|
|
80
|
+
def switch_session(data)
|
|
81
|
+
target_id = data["session_id"].to_i
|
|
82
|
+
return transmit_error("Session not found") unless target_id > 0
|
|
83
|
+
|
|
84
|
+
switch_to_session(target_id)
|
|
85
|
+
rescue ActiveRecord::RecordNotFound
|
|
86
|
+
transmit_error("Session not found")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Changes the session's view mode and re-broadcasts the viewport.
|
|
90
|
+
# All clients on the session receive the mode change and fresh history.
|
|
91
|
+
#
|
|
92
|
+
# @param data [Hash] must include "view_mode" (one of Session::VIEW_MODES)
|
|
93
|
+
def change_view_mode(data)
|
|
94
|
+
mode = data["view_mode"].to_s
|
|
95
|
+
return transmit_error("Invalid view mode") unless Session::VIEW_MODES.include?(mode)
|
|
96
|
+
|
|
97
|
+
session = Session.find(@current_session_id)
|
|
98
|
+
session.update!(view_mode: mode)
|
|
99
|
+
|
|
100
|
+
ActionCable.server.broadcast(stream_name, {"action" => "view_mode_changed", "view_mode" => mode})
|
|
101
|
+
broadcast_viewport(session)
|
|
102
|
+
rescue ActiveRecord::RecordNotFound
|
|
103
|
+
transmit_error("Session not found")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def stream_name
|
|
109
|
+
"session_#{@current_session_id}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Switches the channel to a different session: stops current stream,
|
|
113
|
+
# updates the session reference, starts the new stream, and sends
|
|
114
|
+
# a session_changed signal followed by chat history.
|
|
115
|
+
def switch_to_session(new_id)
|
|
116
|
+
stop_all_streams
|
|
117
|
+
@current_session_id = new_id
|
|
118
|
+
stream_from stream_name
|
|
119
|
+
session = Session.find(new_id)
|
|
120
|
+
transmit({
|
|
121
|
+
"action" => "session_changed",
|
|
122
|
+
"session_id" => new_id,
|
|
123
|
+
"message_count" => session.events.llm_messages.count,
|
|
124
|
+
"view_mode" => session.view_mode
|
|
125
|
+
})
|
|
126
|
+
transmit_history
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Transmits the current view_mode so the TUI initializes correctly.
|
|
130
|
+
# Sends `{action: "view_mode", view_mode: <mode>}` to the subscribing client.
|
|
131
|
+
# @return [void]
|
|
132
|
+
def transmit_view_mode
|
|
133
|
+
session = Session.find_by(id: @current_session_id)
|
|
134
|
+
return unless session
|
|
135
|
+
|
|
136
|
+
transmit({"action" => "view_mode", "view_mode" => session.view_mode})
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Sends decorated context events (messages + tool interactions) from
|
|
140
|
+
# the LLM's viewport to the subscribing client. Each event is wrapped
|
|
141
|
+
# in an {EventDecorator} and the pre-rendered output is included in
|
|
142
|
+
# the transmitted payload. Tool events are included so the TUI can
|
|
143
|
+
# reconstruct tool call counters on reconnect.
|
|
144
|
+
# In debug mode, prepends the assembled system prompt as a special block.
|
|
145
|
+
def transmit_history
|
|
146
|
+
session = Session.find_by(id: @current_session_id)
|
|
147
|
+
return unless session
|
|
148
|
+
|
|
149
|
+
transmit_system_prompt(session) if session.view_mode == "debug"
|
|
150
|
+
|
|
151
|
+
session.viewport_events.each do |event|
|
|
152
|
+
transmit(decorate_event_payload(event, session.view_mode))
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Broadcasts the re-decorated viewport to all clients on the session stream.
|
|
157
|
+
# Used after a view mode change to refresh all connected clients.
|
|
158
|
+
# In debug mode, prepends the assembled system prompt as a special block.
|
|
159
|
+
# @param session [Session] the session whose viewport to broadcast
|
|
160
|
+
# @return [void]
|
|
161
|
+
def broadcast_viewport(session)
|
|
162
|
+
broadcast_system_prompt(session) if session.view_mode == "debug"
|
|
163
|
+
|
|
164
|
+
session.viewport_events.each do |event|
|
|
165
|
+
ActionCable.server.broadcast(stream_name, decorate_event_payload(event, session.view_mode))
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def decorate_event_payload(event, mode = "basic")
|
|
170
|
+
payload = event.payload
|
|
171
|
+
decorator = EventDecorator.for(event)
|
|
172
|
+
return payload unless decorator
|
|
173
|
+
|
|
174
|
+
payload.merge("rendered" => {mode => decorator.render(mode)})
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Transmits the assembled system prompt to the subscribing client.
|
|
178
|
+
# Skipped when the session has no system prompt configured.
|
|
179
|
+
# @param session [Session]
|
|
180
|
+
# @return [void]
|
|
181
|
+
def transmit_system_prompt(session)
|
|
182
|
+
payload = system_prompt_payload(session)
|
|
183
|
+
return unless payload
|
|
184
|
+
|
|
185
|
+
transmit(payload)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Broadcasts the assembled system prompt to all clients on the stream.
|
|
189
|
+
# Skipped when the session has no system prompt configured.
|
|
190
|
+
# @param session [Session]
|
|
191
|
+
# @return [void]
|
|
192
|
+
def broadcast_system_prompt(session)
|
|
193
|
+
payload = system_prompt_payload(session)
|
|
194
|
+
return unless payload
|
|
195
|
+
|
|
196
|
+
ActionCable.server.broadcast(stream_name, payload)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Builds the system prompt payload for debug mode transmission.
|
|
200
|
+
# @param session [Session]
|
|
201
|
+
# @return [Hash, nil] the system prompt payload, or nil if no prompt
|
|
202
|
+
def system_prompt_payload(session)
|
|
203
|
+
prompt = session.system_prompt
|
|
204
|
+
return unless prompt
|
|
205
|
+
|
|
206
|
+
tokens = [(prompt.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
207
|
+
{
|
|
208
|
+
"type" => "system_prompt",
|
|
209
|
+
"rendered" => {
|
|
210
|
+
"debug" => {role: :system_prompt, content: prompt, tokens: tokens, estimated: true}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def transmit_error(message)
|
|
216
|
+
transmit({"action" => "error", "message" => message})
|
|
217
|
+
end
|
|
218
|
+
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,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Decorates agent_message events for display in the TUI.
|
|
4
|
+
# Basic mode returns role and content. Verbose mode adds a timestamp.
|
|
5
|
+
# Debug mode adds token count (exact when counted, estimated when not).
|
|
6
|
+
class AgentMessageDecorator < EventDecorator
|
|
7
|
+
# @return [Hash] structured agent message data
|
|
8
|
+
# `{role: :assistant, content: String}`
|
|
9
|
+
def render_basic
|
|
10
|
+
{role: :assistant, content: content}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [Hash] structured agent message with nanosecond timestamp
|
|
14
|
+
# `{role: :assistant, content: String, timestamp: Integer|nil}`
|
|
15
|
+
def render_verbose
|
|
16
|
+
{role: :assistant, content: content, timestamp: timestamp}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Hash] verbose output plus token count for debugging
|
|
20
|
+
# `{role: :assistant, content: String, timestamp: Integer|nil, tokens: Integer, estimated: Boolean}`
|
|
21
|
+
def render_debug
|
|
22
|
+
render_verbose.merge(token_info)
|
|
23
|
+
end
|
|
24
|
+
end
|