anima-core 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +20 -32
- data/anima-core.gemspec +1 -0
- data/app/channels/session_channel.rb +220 -26
- 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 +35 -0
- data/app/jobs/agent_request_job.rb +31 -2
- data/app/jobs/count_event_tokens_job.rb +14 -3
- data/app/models/concerns/event/broadcasting.rb +63 -0
- data/app/models/event.rb +36 -0
- data/app/models/session.rb +46 -14
- data/config/application.rb +1 -0
- data/config/initializers/event_subscribers.rb +0 -1
- data/config/routes.rb +0 -6
- data/db/cable_schema.rb +14 -2
- data/db/migrate/20260312170000_add_view_mode_to_sessions.rb +7 -0
- data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
- data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
- data/lib/agent_loop.rb +5 -2
- data/lib/anima/cli.rb +1 -40
- data/lib/anima/version.rb +1 -1
- data/lib/events/subscribers/persister.rb +1 -0
- data/lib/events/user_message.rb +17 -0
- data/lib/providers/anthropic.rb +3 -13
- data/lib/tools/edit.rb +227 -0
- data/lib/tools/read.rb +152 -0
- data/lib/tools/write.rb +86 -0
- data/lib/tui/app.rb +831 -55
- data/lib/tui/cable_client.rb +79 -31
- data/lib/tui/input_buffer.rb +181 -0
- data/lib/tui/message_store.rb +162 -14
- data/lib/tui/screens/chat.rb +504 -75
- metadata +30 -5
- data/app/controllers/api/sessions_controller.rb +0 -25
- data/lib/events/subscribers/action_cable_bridge.rb +0 -35
- 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: e3b4a76568897bb6c9d465151e61f6dd889c42c33b71dcf0c019ae561143b2e4
|
|
4
|
+
data.tar.gz: 44acf306c750f33b3bb006c7be209ec80924521f61a89d2e863179672f33751b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bb66a71439efafa605eb7e6124269e82a0183c3d133d7e63658a1bdbe1c4f6adc9a4bf3303fb4bc724337319434eded5de40e9134c456ecbb3bb4508500b9370
|
|
7
|
+
data.tar.gz: b06ae76628e9abd1a76832703bc1769f24ea206c39c86b5fc1ef3bcfe9a59cfe87db222042aa7ea277714d18c3fb9c7c460769974fd75edd3b1f00923560e756
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
### Added
|
|
4
|
+
- Real-time event broadcasting via `Event::Broadcasting` concern — `after_create_commit` and `after_update_commit` callbacks broadcast decorated payloads with database ID and action type to the session's ActionCable stream (#91)
|
|
5
|
+
- TUI `MessageStore` ID-indexed updates — events with `action: "update"` replace existing entries in-place (O(1) lookup) without changing display order
|
|
6
|
+
- `CountEventTokensJob` triggers broadcast — uses `update!` so token count updates push to connected clients in real time
|
|
7
|
+
- Connection status constants in `CableClient` — replaces magic strings with named constants for protocol message types
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- Connection status indicator simplified — emoji-only `🟢` for normal state, descriptive text only for abnormal states (#80)
|
|
11
|
+
- `STATUS_STYLES` structure simplified from `{label, fg, bg}` to `{label, color}` (#80)
|
|
12
|
+
- `ActionCableBridge` removed — broadcasting moved from EventBus subscriber to AR callbacks, eliminating the timing gap where events were broadcast before persistence
|
|
13
|
+
- `SessionChannel` history includes event IDs for client-side correlation
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- TUI showed empty chat on reconnect — message store was cleared _after_ history arrived because `confirm_subscription` comes after `transmit` in Action Cable protocol; now clears on "subscribing" before history (#82)
|
|
17
|
+
|
|
18
|
+
## [0.2.1] - 2026-03-13
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- TUI view mode switching via `Ctrl+a → v` — cycle between Basic, Verbose, and Debug (#75)
|
|
22
|
+
- Draper EventDecorator hierarchy — structured data decorators for all event types (#74)
|
|
23
|
+
- Decorators return structured hashes (not strings) for transport-layer filtering (#86)
|
|
24
|
+
- Basic mode tool call counter — inline `🔧 Tools: X/Y ✓` aggregation (#73)
|
|
25
|
+
- Verbose view mode rendering — timestamps, tool call previews, system messages (#76)
|
|
26
|
+
- Tool call previews: bash `$ command`, web_get `GET url`, generic JSON fallback
|
|
27
|
+
- Tool response display: truncated to 3 lines, `↩` success / `❌` failure indicators
|
|
28
|
+
- Debug view mode — token counts per message, full tool args/responses, tool use IDs (#77)
|
|
29
|
+
- Estimated token indicator (`~` prefix) for events not yet counted by background job
|
|
30
|
+
- View mode persisted on Session model — survives TUI disconnect/reconnect
|
|
31
|
+
- Mode changes broadcast to all connected clients with re-decorated viewport
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
- Newlines in LLM responses collapsed into single line in rendered view modes
|
|
35
|
+
- Loading state stuck after view mode switch — input blocked with "Thinking..."
|
|
36
|
+
|
|
3
37
|
## [0.2.0] - 2026-03-10
|
|
4
38
|
|
|
5
39
|
### Added
|
data/README.md
CHANGED
|
@@ -130,37 +130,13 @@ Updates: `gem update anima-core` — next launch runs pending migrations automat
|
|
|
130
130
|
|
|
131
131
|
### Authentication Setup
|
|
132
132
|
|
|
133
|
-
Anima uses your Claude Pro/Max subscription for API access. You need a setup-token from Claude Code CLI.
|
|
133
|
+
Anima uses your Claude Pro/Max subscription for API access. You need a setup-token from [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code).
|
|
134
134
|
|
|
135
|
-
|
|
135
|
+
1. Run `claude setup-token` in a terminal to get your token
|
|
136
|
+
2. In the TUI, press `Ctrl+a → a` to open the token setup popup
|
|
137
|
+
3. Paste the token and press Enter — Anima validates it against the Anthropic API and saves it to encrypted credentials
|
|
136
138
|
|
|
137
|
-
|
|
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.
|
|
139
|
+
The popup also activates automatically when Anima detects a missing or invalid token. If the token expires, repeat the process with a new one.
|
|
164
140
|
|
|
165
141
|
### Three Layers (mirroring biology)
|
|
166
142
|
|
|
@@ -186,7 +162,7 @@ Five event types form the agent's nervous system:
|
|
|
186
162
|
|
|
187
163
|
Events flow through two channels:
|
|
188
164
|
1. **In-process** — Rails Structured Event Reporter (local subscribers like Persister)
|
|
189
|
-
2. **Over the wire** — Action Cable WebSocket (
|
|
165
|
+
2. **Over the wire** — Action Cable WebSocket (`Event::Broadcasting` callbacks push to connected TUI clients)
|
|
190
166
|
|
|
191
167
|
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
168
|
|
|
@@ -194,7 +170,19 @@ Events fire, subscribers react, state updates, the cortex (LLM) reads the result
|
|
|
194
170
|
|
|
195
171
|
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
172
|
|
|
197
|
-
Currently uses a simple sliding window (newest events first, walk backwards until budget exhausted). Future versions will add
|
|
173
|
+
Currently uses a simple sliding window (newest events first, walk backwards until budget exhausted). Future versions will add associative recall from Mneme.
|
|
174
|
+
|
|
175
|
+
### TUI View Modes
|
|
176
|
+
|
|
177
|
+
Three switchable view modes let you control how much detail the TUI shows. Cycle with `Ctrl+a → v`:
|
|
178
|
+
|
|
179
|
+
| Mode | What you see |
|
|
180
|
+
|------|-------------|
|
|
181
|
+
| **Basic** (default) | User + assistant messages. Tool calls are hidden but summarized as an inline counter: `🔧 Tools: 2/2 ✓` |
|
|
182
|
+
| **Verbose** | Everything in Basic, plus timestamps `[HH:MM:SS]`, tool call previews (`🔧 bash` / `$ command` / `↩ response`), and system messages |
|
|
183
|
+
| **Debug** | Full X-ray view — timestamps, token counts per message (`[14 tok]`), full tool call args, full tool responses, tool use IDs |
|
|
184
|
+
|
|
185
|
+
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.
|
|
198
186
|
|
|
199
187
|
### Brain as Microservices on a Shared Event Bus
|
|
200
188
|
|
|
@@ -331,7 +319,7 @@ This single example demonstrates every core principle:
|
|
|
331
319
|
|
|
332
320
|
## Status
|
|
333
321
|
|
|
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,
|
|
322
|
+
**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.
|
|
335
323
|
|
|
336
324
|
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
325
|
|
data/anima-core.gemspec
CHANGED
|
@@ -28,6 +28,7 @@ Gem::Specification.new do |spec|
|
|
|
28
28
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
29
29
|
spec.require_paths = ["lib"]
|
|
30
30
|
|
|
31
|
+
spec.add_dependency "draper", "~> 4.0"
|
|
31
32
|
spec.add_dependency "foreman", "~> 0.88"
|
|
32
33
|
spec.add_dependency "httparty", "~> 0.24"
|
|
33
34
|
spec.add_dependency "puma", "~> 6.0"
|
|
@@ -14,18 +14,25 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
14
14
|
MAX_LIST_LIMIT = 50
|
|
15
15
|
|
|
16
16
|
# Subscribes the client to the session-specific stream.
|
|
17
|
-
#
|
|
18
|
-
#
|
|
17
|
+
# When a valid session_id is provided, subscribes to that session.
|
|
18
|
+
# When omitted or zero, resolves to the most recent session (creating
|
|
19
|
+
# one if none exist) — this is the CQRS-compliant path where the
|
|
20
|
+
# server owns session resolution instead of a REST endpoint.
|
|
19
21
|
#
|
|
20
|
-
#
|
|
22
|
+
# Always transmits a session_changed signal so the client learns
|
|
23
|
+
# the authoritative session ID, followed by view_mode and history.
|
|
24
|
+
#
|
|
25
|
+
# @param params [Hash] optional :session_id (positive integer)
|
|
21
26
|
def subscribed
|
|
22
|
-
@current_session_id =
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
@current_session_id = resolve_session_id
|
|
28
|
+
stream_from stream_name
|
|
29
|
+
|
|
30
|
+
session = Session.find_by(id: @current_session_id)
|
|
31
|
+
return unless session
|
|
32
|
+
|
|
33
|
+
transmit_session_changed(session)
|
|
34
|
+
transmit_view_mode(session)
|
|
35
|
+
transmit_history(session)
|
|
29
36
|
end
|
|
30
37
|
|
|
31
38
|
# Receives messages from clients and broadcasts them to all session subscribers.
|
|
@@ -36,14 +43,43 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
36
43
|
end
|
|
37
44
|
|
|
38
45
|
# Processes user input: persists the message and enqueues LLM processing.
|
|
46
|
+
# When the session is actively processing an agent request, the message
|
|
47
|
+
# is queued as "pending" and picked up after the current loop completes.
|
|
39
48
|
#
|
|
40
49
|
# @param data [Hash] must include "content" with the user's message text
|
|
41
50
|
def speak(data)
|
|
42
51
|
content = data["content"].to_s.strip
|
|
43
|
-
return if content.empty?
|
|
52
|
+
return if content.empty?
|
|
53
|
+
|
|
54
|
+
session = Session.find_by(id: @current_session_id)
|
|
55
|
+
return unless session
|
|
56
|
+
|
|
57
|
+
if session.processing?
|
|
58
|
+
Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id, status: Event::PENDING_STATUS))
|
|
59
|
+
else
|
|
60
|
+
Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id))
|
|
61
|
+
AgentRequestJob.perform_later(@current_session_id)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Recalls the most recent pending message for editing. Deletes the
|
|
66
|
+
# pending event and broadcasts the recall so all clients remove it.
|
|
67
|
+
#
|
|
68
|
+
# @param data [Hash] must include "event_id" (positive integer)
|
|
69
|
+
def recall_pending(data)
|
|
70
|
+
event_id = data["event_id"].to_i
|
|
71
|
+
return if event_id <= 0
|
|
72
|
+
|
|
73
|
+
event = Event.find_by(
|
|
74
|
+
id: event_id,
|
|
75
|
+
session_id: @current_session_id,
|
|
76
|
+
event_type: "user_message",
|
|
77
|
+
status: Event::PENDING_STATUS
|
|
78
|
+
)
|
|
79
|
+
return unless event
|
|
44
80
|
|
|
45
|
-
|
|
46
|
-
|
|
81
|
+
event.destroy!
|
|
82
|
+
ActionCable.server.broadcast(stream_name, {"action" => "user_message_recalled", "event_id" => event_id})
|
|
47
83
|
end
|
|
48
84
|
|
|
49
85
|
# Returns recent sessions with metadata for session picker UI.
|
|
@@ -85,12 +121,73 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
85
121
|
transmit_error("Session not found")
|
|
86
122
|
end
|
|
87
123
|
|
|
124
|
+
# Validates and saves an Anthropic subscription token to encrypted credentials.
|
|
125
|
+
# Format-validated and API-validated before storage. The token never enters the
|
|
126
|
+
# LLM context window — it flows directly from WebSocket to encrypted credentials.
|
|
127
|
+
#
|
|
128
|
+
# @param data [Hash] must include "token" (Anthropic subscription token string)
|
|
129
|
+
def save_token(data)
|
|
130
|
+
token = data["token"].to_s.strip
|
|
131
|
+
|
|
132
|
+
Providers::Anthropic.validate_token_format!(token)
|
|
133
|
+
Providers::Anthropic.validate_token_api!(token)
|
|
134
|
+
write_anthropic_token(token)
|
|
135
|
+
|
|
136
|
+
transmit({"action" => "token_saved"})
|
|
137
|
+
rescue Providers::Anthropic::TokenFormatError, Providers::Anthropic::AuthenticationError => error
|
|
138
|
+
transmit({"action" => "token_error", "message" => error.message})
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Changes the session's view mode and re-broadcasts the viewport.
|
|
142
|
+
# All clients on the session receive the mode change and fresh history.
|
|
143
|
+
#
|
|
144
|
+
# @param data [Hash] must include "view_mode" (one of Session::VIEW_MODES)
|
|
145
|
+
def change_view_mode(data)
|
|
146
|
+
mode = data["view_mode"].to_s
|
|
147
|
+
return transmit_error("Invalid view mode") unless Session::VIEW_MODES.include?(mode)
|
|
148
|
+
|
|
149
|
+
session = Session.find(@current_session_id)
|
|
150
|
+
session.update!(view_mode: mode)
|
|
151
|
+
|
|
152
|
+
ActionCable.server.broadcast(stream_name, {"action" => "view_mode_changed", "view_mode" => mode})
|
|
153
|
+
broadcast_viewport(session)
|
|
154
|
+
rescue ActiveRecord::RecordNotFound
|
|
155
|
+
transmit_error("Session not found")
|
|
156
|
+
end
|
|
157
|
+
|
|
88
158
|
private
|
|
89
159
|
|
|
90
160
|
def stream_name
|
|
91
161
|
"session_#{@current_session_id}"
|
|
92
162
|
end
|
|
93
163
|
|
|
164
|
+
# Resolves the session to subscribe to. Uses the client-provided ID
|
|
165
|
+
# when valid, otherwise falls back to the most recent session or
|
|
166
|
+
# creates a new one.
|
|
167
|
+
#
|
|
168
|
+
# @return [Integer] resolved session ID
|
|
169
|
+
def resolve_session_id
|
|
170
|
+
id = params[:session_id].to_i
|
|
171
|
+
return id if id > 0
|
|
172
|
+
|
|
173
|
+
(Session.recent(1).first || Session.create!).id
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Transmits session metadata as a session_changed signal.
|
|
177
|
+
# Used on initial subscription and after session switches so the
|
|
178
|
+
# client can handle both paths with a single code path.
|
|
179
|
+
#
|
|
180
|
+
# @param session [Session] the session to announce
|
|
181
|
+
# @return [void]
|
|
182
|
+
def transmit_session_changed(session)
|
|
183
|
+
transmit({
|
|
184
|
+
"action" => "session_changed",
|
|
185
|
+
"session_id" => session.id,
|
|
186
|
+
"message_count" => session.events.llm_messages.count,
|
|
187
|
+
"view_mode" => session.view_mode
|
|
188
|
+
})
|
|
189
|
+
end
|
|
190
|
+
|
|
94
191
|
# Switches the channel to a different session: stops current stream,
|
|
95
192
|
# updates the session reference, starts the new stream, and sends
|
|
96
193
|
# a session_changed signal followed by chat history.
|
|
@@ -98,26 +195,123 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
98
195
|
stop_all_streams
|
|
99
196
|
@current_session_id = new_id
|
|
100
197
|
stream_from stream_name
|
|
198
|
+
|
|
101
199
|
session = Session.find(new_id)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
"session_id" => new_id,
|
|
105
|
-
"message_count" => session.events.llm_messages.count
|
|
106
|
-
})
|
|
107
|
-
transmit_history
|
|
200
|
+
transmit_session_changed(session)
|
|
201
|
+
transmit_history(session)
|
|
108
202
|
end
|
|
109
203
|
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
204
|
+
# Transmits the current view_mode so the TUI initializes correctly.
|
|
205
|
+
# Sends `{action: "view_mode", view_mode: <mode>}` to the subscribing client.
|
|
206
|
+
#
|
|
207
|
+
# @param session [Session] the session whose view_mode to transmit
|
|
208
|
+
# @return [void]
|
|
209
|
+
def transmit_view_mode(session)
|
|
210
|
+
transmit({"action" => "view_mode", "view_mode" => session.view_mode})
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Sends decorated context events (messages + tool interactions) from
|
|
214
|
+
# the LLM's viewport to the subscribing client. Each event is wrapped
|
|
215
|
+
# in an {EventDecorator} and the pre-rendered output is included in
|
|
216
|
+
# the transmitted payload. Tool events are included so the TUI can
|
|
217
|
+
# reconstruct tool call counters on reconnect.
|
|
218
|
+
# In debug mode, prepends the assembled system prompt as a special block.
|
|
219
|
+
#
|
|
220
|
+
# @param session [Session] the session whose history to transmit
|
|
221
|
+
def transmit_history(session)
|
|
222
|
+
transmit_system_prompt(session) if session.view_mode == "debug"
|
|
223
|
+
|
|
224
|
+
session.viewport_events.each do |event|
|
|
225
|
+
transmit(decorate_event_payload(event, session.view_mode))
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Broadcasts the re-decorated viewport to all clients on the session stream.
|
|
230
|
+
# Used after a view mode change to refresh all connected clients.
|
|
231
|
+
# In debug mode, prepends the assembled system prompt as a special block.
|
|
232
|
+
# @param session [Session] the session whose viewport to broadcast
|
|
233
|
+
# @return [void]
|
|
234
|
+
def broadcast_viewport(session)
|
|
235
|
+
broadcast_system_prompt(session) if session.view_mode == "debug"
|
|
115
236
|
|
|
116
237
|
session.viewport_events.each do |event|
|
|
117
|
-
|
|
238
|
+
ActionCable.server.broadcast(stream_name, decorate_event_payload(event, session.view_mode))
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Decorates an event for transmission to clients. Merges the event's
|
|
243
|
+
# database ID and structured decorator output into the payload.
|
|
244
|
+
# Used by {#transmit_history} and {#broadcast_viewport} for historical
|
|
245
|
+
# and viewport re-broadcast — live broadcasts use {Event::Broadcasting}.
|
|
246
|
+
#
|
|
247
|
+
# @param event [Event] persisted event record
|
|
248
|
+
# @param mode [String] view mode for decoration (default: "basic")
|
|
249
|
+
# @return [Hash] payload with "id" and optional "rendered" key
|
|
250
|
+
def decorate_event_payload(event, mode = "basic")
|
|
251
|
+
payload = event.payload.merge("id" => event.id)
|
|
252
|
+
decorator = EventDecorator.for(event)
|
|
253
|
+
return payload unless decorator
|
|
254
|
+
|
|
255
|
+
payload.merge("rendered" => {mode => decorator.render(mode)})
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Transmits the assembled system prompt to the subscribing client.
|
|
259
|
+
# Skipped when the session has no system prompt configured.
|
|
260
|
+
# @param session [Session]
|
|
261
|
+
# @return [void]
|
|
262
|
+
def transmit_system_prompt(session)
|
|
263
|
+
payload = system_prompt_payload(session)
|
|
264
|
+
return unless payload
|
|
265
|
+
|
|
266
|
+
transmit(payload)
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Broadcasts the assembled system prompt to all clients on the stream.
|
|
270
|
+
# Skipped when the session has no system prompt configured.
|
|
271
|
+
# @param session [Session]
|
|
272
|
+
# @return [void]
|
|
273
|
+
def broadcast_system_prompt(session)
|
|
274
|
+
payload = system_prompt_payload(session)
|
|
275
|
+
return unless payload
|
|
276
|
+
|
|
277
|
+
ActionCable.server.broadcast(stream_name, payload)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Builds the system prompt payload for debug mode transmission.
|
|
281
|
+
# @param session [Session]
|
|
282
|
+
# @return [Hash, nil] the system prompt payload, or nil if no prompt
|
|
283
|
+
def system_prompt_payload(session)
|
|
284
|
+
prompt = session.system_prompt
|
|
285
|
+
return unless prompt
|
|
118
286
|
|
|
119
|
-
|
|
287
|
+
tokens = [(prompt.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
288
|
+
{
|
|
289
|
+
"type" => "system_prompt",
|
|
290
|
+
"rendered" => {
|
|
291
|
+
"debug" => {role: :system_prompt, content: prompt, tokens: tokens, estimated: true}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Merges the Anthropic subscription token into encrypted credentials,
|
|
297
|
+
# preserving existing keys (e.g. secret_key_base).
|
|
298
|
+
#
|
|
299
|
+
# @param token [String] validated Anthropic subscription token
|
|
300
|
+
# @return [void]
|
|
301
|
+
def write_anthropic_token(token)
|
|
302
|
+
creds = Rails.application.credentials
|
|
303
|
+
existing = begin
|
|
304
|
+
YAML.safe_load(creds.read) || {}
|
|
305
|
+
rescue ActiveSupport::EncryptedFile::MissingContentError
|
|
306
|
+
{}
|
|
120
307
|
end
|
|
308
|
+
existing["anthropic"] ||= {}
|
|
309
|
+
existing["anthropic"]["subscription_token"] = token
|
|
310
|
+
creds.write(existing.to_yaml)
|
|
311
|
+
# Rails memoizes the decrypted config in @config. Without clearing it,
|
|
312
|
+
# subsequent credential reads return stale data. No public API exists
|
|
313
|
+
# for cache invalidation as of Rails 8.1.
|
|
314
|
+
creds.instance_variable_set(:@config, nil)
|
|
121
315
|
end
|
|
122
316
|
|
|
123
317
|
def transmit_error(message)
|
|
@@ -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,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Base decorator for {Event} records, providing multi-resolution rendering
|
|
4
|
+
# for the TUI. Each event type has a dedicated subclass that implements
|
|
5
|
+
# rendering methods for each view mode (basic, verbose, debug).
|
|
6
|
+
#
|
|
7
|
+
# Decorators return structured hashes (not pre-formatted strings) so that
|
|
8
|
+
# the TUI can style and lay out content based on semantic role, without
|
|
9
|
+
# fragile regex parsing. The TUI receives structured data via ActionCable
|
|
10
|
+
# and formats it for display.
|
|
11
|
+
#
|
|
12
|
+
# Subclasses must override {#render_basic}. Verbose and debug modes
|
|
13
|
+
# delegate to basic until subclasses provide their own implementations.
|
|
14
|
+
#
|
|
15
|
+
# @example Decorate an Event AR model
|
|
16
|
+
# decorator = EventDecorator.for(event)
|
|
17
|
+
# decorator.render_basic #=> {role: :user, content: "hello"} or nil
|
|
18
|
+
#
|
|
19
|
+
# @example Render for a specific view mode
|
|
20
|
+
# decorator = EventDecorator.for(event)
|
|
21
|
+
# decorator.render("verbose") #=> {role: :user, content: "hello", timestamp: 1709312325000000000}
|
|
22
|
+
#
|
|
23
|
+
# @example Decorate a raw payload hash (from EventBus)
|
|
24
|
+
# decorator = EventDecorator.for(type: "user_message", content: "hello")
|
|
25
|
+
# decorator.render_basic #=> {role: :user, content: "hello"}
|
|
26
|
+
class EventDecorator < ApplicationDecorator
|
|
27
|
+
delegate_all
|
|
28
|
+
|
|
29
|
+
TOOL_ICON = "\u{1F527}"
|
|
30
|
+
RETURN_ARROW = "\u21A9"
|
|
31
|
+
ERROR_ICON = "\u274C"
|
|
32
|
+
|
|
33
|
+
DECORATOR_MAP = {
|
|
34
|
+
"user_message" => "UserMessageDecorator",
|
|
35
|
+
"agent_message" => "AgentMessageDecorator",
|
|
36
|
+
"tool_call" => "ToolCallDecorator",
|
|
37
|
+
"tool_response" => "ToolResponseDecorator",
|
|
38
|
+
"system_message" => "SystemMessageDecorator"
|
|
39
|
+
}.freeze
|
|
40
|
+
private_constant :DECORATOR_MAP
|
|
41
|
+
|
|
42
|
+
# Normalizes hash payloads into an Event-like interface so decorators
|
|
43
|
+
# can use {#payload}, {#event_type}, etc. uniformly on both AR models
|
|
44
|
+
# and raw EventBus hashes.
|
|
45
|
+
#
|
|
46
|
+
# @!attribute event_type [r] the event's type (e.g. "user_message")
|
|
47
|
+
# @!attribute payload [r] string-keyed hash of event data
|
|
48
|
+
# @!attribute timestamp [r] nanosecond-precision timestamp
|
|
49
|
+
# @!attribute token_count [r] cumulative token count
|
|
50
|
+
EventPayload = Struct.new(:event_type, :payload, :timestamp, :token_count, keyword_init: true) do
|
|
51
|
+
# Heuristic token estimate matching {Event#estimate_tokens} so decorators
|
|
52
|
+
# can call it uniformly on both AR models and hash payloads.
|
|
53
|
+
# @return [Integer] at least 1
|
|
54
|
+
def estimate_tokens
|
|
55
|
+
text = if event_type.to_s.in?(%w[tool_call tool_response])
|
|
56
|
+
payload.to_json
|
|
57
|
+
else
|
|
58
|
+
payload&.dig("content").to_s
|
|
59
|
+
end
|
|
60
|
+
[(text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Factory returning the appropriate subclass decorator for the given event.
|
|
65
|
+
# Hashes are normalized via {EventPayload} to provide a uniform interface.
|
|
66
|
+
#
|
|
67
|
+
# @param event [Event, Hash] an Event AR model or a raw payload hash
|
|
68
|
+
# @return [EventDecorator, nil] decorated event, or nil for unknown types
|
|
69
|
+
def self.for(event)
|
|
70
|
+
source = wrap_source(event)
|
|
71
|
+
klass_name = DECORATOR_MAP[source.event_type]
|
|
72
|
+
return nil unless klass_name
|
|
73
|
+
|
|
74
|
+
klass_name.constantize.new(source)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
RENDER_DISPATCH = {
|
|
78
|
+
"basic" => :render_basic,
|
|
79
|
+
"verbose" => :render_verbose,
|
|
80
|
+
"debug" => :render_debug
|
|
81
|
+
}.freeze
|
|
82
|
+
private_constant :RENDER_DISPATCH
|
|
83
|
+
|
|
84
|
+
# Dispatches to the render method for the given view mode.
|
|
85
|
+
#
|
|
86
|
+
# @param mode [String] one of "basic", "verbose", "debug"
|
|
87
|
+
# @return [Hash, nil] structured event data, or nil to hide the event
|
|
88
|
+
# @raise [ArgumentError] if the mode is not a valid view mode
|
|
89
|
+
def render(mode)
|
|
90
|
+
method = RENDER_DISPATCH[mode]
|
|
91
|
+
raise ArgumentError, "Invalid view mode: #{mode.inspect}" unless method
|
|
92
|
+
|
|
93
|
+
public_send(method)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @abstract Subclasses must implement to render the event for basic view mode.
|
|
97
|
+
# @return [Hash, nil] structured event data, or nil to hide the event
|
|
98
|
+
def render_basic
|
|
99
|
+
raise NotImplementedError, "#{self.class} must implement #render_basic"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Verbose view mode with timestamps and tool details.
|
|
103
|
+
# Delegates to {#render_basic} until subclasses provide their own implementations.
|
|
104
|
+
# @return [Hash, nil] structured event data, or nil to hide the event
|
|
105
|
+
def render_verbose
|
|
106
|
+
render_basic
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Debug view mode with token counts and system prompts.
|
|
110
|
+
# Delegates to {#render_basic} until subclasses provide their own implementations.
|
|
111
|
+
# @return [Hash, nil] structured event data, or nil to hide the event
|
|
112
|
+
def render_debug
|
|
113
|
+
render_basic
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# Token count for display: exact count from {CountEventTokensJob} when
|
|
119
|
+
# available, heuristic estimate otherwise. Estimated counts are flagged
|
|
120
|
+
# so the TUI can prefix them with a tilde.
|
|
121
|
+
#
|
|
122
|
+
# @return [Hash] `{tokens: Integer, estimated: Boolean}`
|
|
123
|
+
def token_info
|
|
124
|
+
count = token_count.to_i
|
|
125
|
+
if count > 0
|
|
126
|
+
{tokens: count, estimated: false}
|
|
127
|
+
else
|
|
128
|
+
{tokens: estimate_token_count, estimated: true}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Delegates to the underlying object's heuristic token estimator.
|
|
133
|
+
# Both {Event} AR models and {EventPayload} structs implement this.
|
|
134
|
+
#
|
|
135
|
+
# @return [Integer] at least 1
|
|
136
|
+
def estimate_token_count
|
|
137
|
+
object.estimate_tokens
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Extracts display content from the event payload.
|
|
141
|
+
# @return [String, nil]
|
|
142
|
+
def content
|
|
143
|
+
payload["content"]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Truncates multi-line text, appending "..." when lines exceed the limit.
|
|
147
|
+
# @param text [String, nil] text to truncate (nil is coerced to empty string)
|
|
148
|
+
# @param max_lines [Integer] maximum number of lines to keep
|
|
149
|
+
# @return [String] truncated text
|
|
150
|
+
def truncate_lines(text, max_lines:)
|
|
151
|
+
str = text.to_s
|
|
152
|
+
lines = str.split("\n")
|
|
153
|
+
return str unless lines.size > max_lines
|
|
154
|
+
|
|
155
|
+
lines.first(max_lines).push("...").join("\n")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Normalizes input to something Draper can wrap.
|
|
159
|
+
# Event AR models pass through; hashes become EventPayload structs
|
|
160
|
+
# with string-normalized keys.
|
|
161
|
+
def self.wrap_source(event)
|
|
162
|
+
return event unless event.is_a?(Hash)
|
|
163
|
+
|
|
164
|
+
normalized = event.transform_keys(&:to_s)
|
|
165
|
+
EventPayload.new(
|
|
166
|
+
event_type: normalized["type"].to_s,
|
|
167
|
+
payload: normalized,
|
|
168
|
+
timestamp: normalized["timestamp"],
|
|
169
|
+
token_count: normalized["token_count"]&.to_i || 0
|
|
170
|
+
)
|
|
171
|
+
end
|
|
172
|
+
private_class_method :wrap_source
|
|
173
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Decorates system_message events for display in the TUI.
|
|
4
|
+
# Hidden in basic mode. Verbose and debug modes return timestamped system info.
|
|
5
|
+
class SystemMessageDecorator < EventDecorator
|
|
6
|
+
# @return [nil] system messages are hidden in basic mode
|
|
7
|
+
def render_basic
|
|
8
|
+
nil
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# @return [Hash] structured system message data
|
|
12
|
+
# `{role: :system, content: String, timestamp: Integer|nil}`
|
|
13
|
+
def render_verbose
|
|
14
|
+
{role: :system, content: content, timestamp: timestamp}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# @return [Hash] same as verbose — system messages have no additional debug data
|
|
18
|
+
def render_debug
|
|
19
|
+
render_verbose
|
|
20
|
+
end
|
|
21
|
+
end
|