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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -3
  3. data/Gemfile +17 -0
  4. data/Procfile +2 -0
  5. data/Procfile.dev +2 -0
  6. data/README.md +68 -26
  7. data/Rakefile +19 -7
  8. data/anima-core.gemspec +41 -0
  9. data/app/channels/application_cable/channel.rb +6 -0
  10. data/app/channels/application_cable/connection.rb +6 -0
  11. data/app/channels/session_channel.rb +218 -0
  12. data/app/controllers/api/sessions_controller.rb +25 -0
  13. data/app/controllers/application_controller.rb +4 -0
  14. data/app/decorators/agent_message_decorator.rb +24 -0
  15. data/app/decorators/application_decorator.rb +6 -0
  16. data/app/decorators/event_decorator.rb +173 -0
  17. data/app/decorators/system_message_decorator.rb +21 -0
  18. data/app/decorators/tool_call_decorator.rb +48 -0
  19. data/app/decorators/tool_response_decorator.rb +37 -0
  20. data/app/decorators/user_message_decorator.rb +24 -0
  21. data/app/jobs/agent_request_job.rb +59 -0
  22. data/app/jobs/count_event_tokens_job.rb +1 -1
  23. data/app/models/event.rb +17 -0
  24. data/app/models/session.rb +40 -19
  25. data/bin/jobs +6 -0
  26. data/bin/rails +6 -0
  27. data/bin/rake +6 -0
  28. data/config/application.rb +5 -0
  29. data/config/cable.yml +14 -0
  30. data/config/database.yml +12 -0
  31. data/config/initializers/event_subscribers.rb +11 -0
  32. data/config/puma.rb +13 -0
  33. data/config/routes.rb +8 -0
  34. data/config.ru +5 -0
  35. data/db/cable_schema.rb +23 -0
  36. data/db/migrate/20260312170000_add_view_mode_to_sessions.rb +7 -0
  37. data/lib/agent_loop.rb +97 -0
  38. data/lib/anima/cli.rb +64 -9
  39. data/lib/anima/installer.rb +4 -3
  40. data/lib/anima/version.rb +1 -1
  41. data/lib/anima.rb +1 -0
  42. data/lib/events/subscribers/action_cable_bridge.rb +59 -0
  43. data/lib/events/subscribers/persister.rb +14 -4
  44. data/lib/providers/anthropic.rb +11 -2
  45. data/lib/tui/app.rb +209 -45
  46. data/lib/tui/cable_client.rb +387 -0
  47. data/lib/tui/input_buffer.rb +181 -0
  48. data/lib/tui/message_store.rb +122 -0
  49. data/lib/tui/screens/chat.rb +567 -88
  50. metadata +103 -5
  51. data/lib/tui/screens/anthropic.rb +0 -25
  52. data/lib/tui/screens/settings.rb +0 -52
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f7039906ee15401ed13db1ceaa640b088540903b1f0b814463fe69d91f6fa02
4
- data.tar.gz: dee3280a42efa74aa78b3499f8c6687a937287eda9099e2b7de09d4ccf761021
3
+ metadata.gz: 9517757f2c4fdb8b19d204a154d3badd3c3b8fb456dffaf03236b2d7c065378d
4
+ data.tar.gz: a0d26e0b27fd1d2df0c46c3a4bbad822ac3517d1d02b41a6bdd49195de64e71f
5
5
  SHA512:
6
- metadata.gz: 1ed4a8551e9ba05919e95a4879bf2366e9cd4986726ebe0ed5900007bd6d06a53639133be5a286d38e41a7f269db78b3575ff982c23d2b4096f804c3528b02a0
7
- data.tar.gz: 65f6d5dbe475dd36af817a3f2eb88c6f6913604de6bfc9dd56775d18abf8e47e0269862e669a8f739ee57fa99a9aede77d343c692400a44a4e873d28ee482c7a
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/Action Cable)
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
@@ -0,0 +1,2 @@
1
+ web: bin/rails server
2
+ worker: bin/jobs
data/Procfile.dev ADDED
@@ -0,0 +1,2 @@
1
+ web: bin/rails server
2
+ worker: bin/jobs
data/README.md CHANGED
@@ -1,9 +1,8 @@
1
- # Anima Framework
1
+ # Anima
2
2
 
3
- **A soul engine for AI agents.**
3
+ **A personal AI agent that actually wants things.**
4
4
 
5
- Ruby framework for building AI agents with desires, personality, and personal growth.
6
- Headless Rails 8.1 app distributed as a gem, with a TUI-first interface via RatatuiRuby.
5
+ Your agent. Your machine. Your rules. Anima is an AI agent with desires, personality, and personal growth — running locally as a headless Rails 8.1 app with a client-server architecture and TUI interface.
7
6
 
8
7
  ## The Problem
9
8
 
@@ -61,22 +60,41 @@ Existing RL techniques apply at the starting point, then we gradually expand int
61
60
  ## Architecture
62
61
 
63
62
  ```
64
- Anima Framework (Ruby, Rails 8.1 headless)
63
+ Anima (Ruby, Rails 8.1 headless)
65
64
  ├── Thymos — hormonal/desire system (stimulus → hormone vector)
66
65
  ├── Mneme — semantic memory (QMD-style, emotional recall)
67
66
  ├── Psyche — soul matrix (coefficient table, evolving through experience)
68
67
  └── Nous — LLM integration (cortex, thinking, decision-making)
69
68
  ```
70
69
 
70
+ ### Runtime Architecture
71
+
72
+ Anima runs as a client-server system:
73
+
74
+ ```
75
+ Brain Server (Rails + Puma) TUI Client (RatatuiRuby)
76
+ ├── LLM integration (Anthropic) ├── WebSocket client
77
+ ├── Agent loop + tool execution ├── Terminal rendering
78
+ ├── Event bus + persistence └── User input capture
79
+ ├── Solid Queue (background jobs)
80
+ ├── Action Cable (WebSocket server)
81
+ └── SQLite databases ◄── WebSocket (port 42134) ──► TUI
82
+ ```
83
+
84
+ The **Brain** is the persistent service — it handles LLM calls, tool execution, event processing, and state. The **TUI** is a stateless client — it connects via WebSocket, renders events, and captures input. If TUI disconnects, the brain keeps running. TUI reconnects automatically with exponential backoff and resumes the session with chat history preserved.
85
+
71
86
  ### Tech Stack
72
87
 
73
88
  | Component | Technology |
74
89
  |-----------|-----------|
75
90
  | Framework | Rails 8.1 (headless — no web views, no asset pipeline) |
76
- | Database | SQLite (per environment, stored in `~/.anima/db/`) |
77
- | Event system | Rails Structured Event Reporter |
78
- | LLM integration | Raw HTTP to Anthropic API |
79
- | Interface | TUI via RatatuiRuby |
91
+ | Database | SQLite (3 databases per environment: primary, queue, cable) |
92
+ | Event system | Rails Structured Event Reporter + Action Cable bridge |
93
+ | LLM integration | Anthropic API (Claude Sonnet 4) |
94
+ | Transport | Action Cable WebSocket (Solid Cable adapter) |
95
+ | Background jobs | Solid Queue |
96
+ | Interface | TUI via RatatuiRuby (WebSocket client) |
97
+ | Process management | Foreman |
80
98
  | Distribution | RubyGems (`gem install anima-core`) |
81
99
 
82
100
  ### Distribution Model
@@ -85,8 +103,16 @@ Anima is a Rails app distributed as a gem, following Unix philosophy: immutable
85
103
 
86
104
  ```bash
87
105
  gem install anima-core # Install the Rails app as a gem
88
- anima install # Create ~/.anima/ directory structure
89
- anima start # Launch code from gem, state from ~/.anima/
106
+ anima install # Create ~/.anima/, set up databases, start brain as systemd service
107
+ anima tui # Connect the terminal interface
108
+ ```
109
+
110
+ The installer creates a systemd user service that starts the brain automatically on login. Manage it with:
111
+
112
+ ```bash
113
+ systemctl --user status anima # Check brain status
114
+ systemctl --user restart anima # Restart brain
115
+ journalctl --user -u anima # View logs
90
116
  ```
91
117
 
92
118
  State directory (`~/.anima/`):
@@ -158,13 +184,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
- POC uses a simple sliding window (newest events first, walk backwards until budget exhausted). Future versions will add multi-resolution compression with Draper decorators and associative recall from Mneme.
197
+ Currently uses a simple sliding window (newest events first, walk backwards until budget exhausted). Future versions will add 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. In POC, tools are built-in; plugin extraction comes later.
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
- POC stage. Gem scaffold with CI and RubyGems publishing exists. Building toward a working conversational agent with event-driven architecture.
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 deferredPOC focuses on getting the core agent loop working first.
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
- bundle install
314
- bundle exec rspec
355
+ bin/setup
315
356
  ```
316
357
 
317
- ### Running the TUI
358
+ ### Running Anima
318
359
 
319
- The TUI requires a background job worker for async token counting (used by the sliding viewport). Start both in separate terminals:
360
+ Start the brain server and TUI client in separate terminals:
320
361
 
321
362
  ```bash
322
- # Terminal 1: Start Solid Queue worker
323
- RAILS_ENV=development bundle exec rake solid_queue:start
363
+ # Terminal 1: Start brain (web server + background worker) on port 42135
364
+ bin/dev
324
365
 
325
- # Terminal 2: Launch the TUI
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, initialize the databases:
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
- RAILS_ENV=development bundle exec rails db:migrate
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
- require "bundler/gem_tasks"
4
- require "rspec/core/rake_task"
3
+ require_relative "config/application"
4
+ Rails.application.load_tasks
5
5
 
6
- RSpec::Core::RakeTask.new(:spec)
6
+ begin
7
+ require "bundler/gem_tasks"
8
+ rescue LoadError
9
+ # bundler not available in gem install context
10
+ end
7
11
 
8
- require "standard/rake"
12
+ begin
13
+ require "rspec/core/rake_task"
14
+ RSpec::Core::RakeTask.new(:spec)
15
+ rescue LoadError
16
+ # rspec not available in gem install context
17
+ end
9
18
 
10
- require_relative "config/application"
11
- Rails.application.load_tasks
19
+ begin
20
+ require "standard/rake"
21
+ rescue LoadError
22
+ # standard not available in gem install context
23
+ end
12
24
 
13
- task default: %i[spec standard]
25
+ task default: %i[spec standard] if Rake::Task.task_defined?(:spec) && Rake::Task.task_defined?(:standard)
@@ -0,0 +1,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,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,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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ApplicationController < ActionController::API
4
+ 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
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base decorator for the application. All Draper decorators inherit from
4
+ # this class to share common configuration and helpers.
5
+ class ApplicationDecorator < Draper::Decorator
6
+ end