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
|
@@ -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
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Decorates tool_call events for display in the TUI.
|
|
4
|
+
# Hidden in basic mode — tool activity is represented by the
|
|
5
|
+
# aggregated tool counter instead. Verbose mode returns tool name
|
|
6
|
+
# and a formatted preview of the input arguments. Debug mode shows
|
|
7
|
+
# full untruncated input as pretty-printed JSON with tool_use_id.
|
|
8
|
+
class ToolCallDecorator < EventDecorator
|
|
9
|
+
# @return [nil] tool calls are hidden in basic mode
|
|
10
|
+
def render_basic
|
|
11
|
+
nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [Hash] structured tool call data
|
|
15
|
+
# `{role: :tool_call, tool: String, input: String, timestamp: Integer|nil}`
|
|
16
|
+
def render_verbose
|
|
17
|
+
{role: :tool_call, tool: payload["tool_name"], input: format_input, timestamp: timestamp}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Hash] full tool call data with untruncated input and tool_use_id
|
|
21
|
+
# `{role: :tool_call, tool: String, input: String, tool_use_id: String|nil, timestamp: Integer|nil}`
|
|
22
|
+
def render_debug
|
|
23
|
+
{
|
|
24
|
+
role: :tool_call,
|
|
25
|
+
tool: payload["tool_name"],
|
|
26
|
+
input: JSON.pretty_generate(payload["tool_input"] || {}),
|
|
27
|
+
tool_use_id: payload["tool_use_id"],
|
|
28
|
+
timestamp: timestamp
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# Formats tool input for display, with tool-specific formatting for
|
|
35
|
+
# known tools and generic JSON fallback for others.
|
|
36
|
+
# @return [String] formatted input preview
|
|
37
|
+
def format_input
|
|
38
|
+
input = payload["tool_input"]
|
|
39
|
+
case payload["tool_name"]
|
|
40
|
+
when "bash"
|
|
41
|
+
"$ #{input&.dig("command")}"
|
|
42
|
+
when "web_get"
|
|
43
|
+
"GET #{input&.dig("url")}"
|
|
44
|
+
else
|
|
45
|
+
truncate_lines(input.to_json, max_lines: 2)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Decorates tool_response events for display in the TUI.
|
|
4
|
+
# Hidden in basic mode — tool activity is represented by the
|
|
5
|
+
# aggregated tool counter instead. Verbose mode returns truncated
|
|
6
|
+
# output with a success/failure indicator. Debug mode shows full
|
|
7
|
+
# untruncated output with tool_use_id and estimated token count.
|
|
8
|
+
class ToolResponseDecorator < EventDecorator
|
|
9
|
+
# @return [nil] tool responses are hidden in basic mode
|
|
10
|
+
def render_basic
|
|
11
|
+
nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# @return [Hash] structured tool response data
|
|
15
|
+
# `{role: :tool_response, content: String, success: Boolean, timestamp: Integer|nil}`
|
|
16
|
+
def render_verbose
|
|
17
|
+
{
|
|
18
|
+
role: :tool_response,
|
|
19
|
+
content: truncate_lines(content, max_lines: 3),
|
|
20
|
+
success: payload["success"] != false,
|
|
21
|
+
timestamp: timestamp
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Hash] full tool response data with untruncated content, tool_use_id, and token estimate
|
|
26
|
+
# `{role: :tool_response, content: String, success: Boolean, tool_use_id: String|nil,
|
|
27
|
+
# timestamp: Integer|nil, tokens: Integer, estimated: Boolean}`
|
|
28
|
+
def render_debug
|
|
29
|
+
{
|
|
30
|
+
role: :tool_response,
|
|
31
|
+
content: content,
|
|
32
|
+
success: payload["success"] != false,
|
|
33
|
+
tool_use_id: payload["tool_use_id"],
|
|
34
|
+
timestamp: timestamp
|
|
35
|
+
}.merge(token_info)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Decorates user_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 UserMessageDecorator < EventDecorator
|
|
7
|
+
# @return [Hash] structured user message data
|
|
8
|
+
# `{role: :user, content: String}`
|
|
9
|
+
def render_basic
|
|
10
|
+
{role: :user, content: content}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# @return [Hash] structured user message with nanosecond timestamp
|
|
14
|
+
# `{role: :user, content: String, timestamp: Integer|nil}`
|
|
15
|
+
def render_verbose
|
|
16
|
+
{role: :user, content: content, timestamp: timestamp}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Hash] verbose output plus token count for debugging
|
|
20
|
+
# `{role: :user, 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,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Executes an LLM agent loop as a background job with retry logic
|
|
4
|
+
# for transient failures (network errors, rate limits, server errors).
|
|
5
|
+
#
|
|
6
|
+
# Emits events via {Events::Bus} as it progresses, making results visible
|
|
7
|
+
# to any subscriber (TUI, WebSocket clients). All retry and failure
|
|
8
|
+
# notifications are emitted as {Events::SystemMessage} to avoid polluting
|
|
9
|
+
# the LLM context window.
|
|
10
|
+
#
|
|
11
|
+
# @example Inline execution (TUI)
|
|
12
|
+
# AgentRequestJob.perform_now(session.id)
|
|
13
|
+
#
|
|
14
|
+
# @example Background execution (future Brain/TUI separation)
|
|
15
|
+
# AgentRequestJob.perform_later(session.id)
|
|
16
|
+
class AgentRequestJob < ApplicationJob
|
|
17
|
+
queue_as :default
|
|
18
|
+
|
|
19
|
+
retry_on Providers::Anthropic::TransientError,
|
|
20
|
+
wait: :polynomially_longer, attempts: 5 do |job, error|
|
|
21
|
+
Events::Bus.emit(Events::SystemMessage.new(
|
|
22
|
+
content: "Failed after multiple retries: #{error.message}",
|
|
23
|
+
session_id: job.arguments.first
|
|
24
|
+
))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
discard_on ActiveRecord::RecordNotFound
|
|
28
|
+
discard_on Providers::Anthropic::AuthenticationError do |job, error|
|
|
29
|
+
Events::Bus.emit(Events::SystemMessage.new(
|
|
30
|
+
content: "Authentication failed: #{error.message}",
|
|
31
|
+
session_id: job.arguments.first
|
|
32
|
+
))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @param session_id [Integer] ID of the session to process
|
|
36
|
+
def perform(session_id)
|
|
37
|
+
session = Session.find(session_id)
|
|
38
|
+
agent_loop = AgentLoop.new(session: session)
|
|
39
|
+
agent_loop.run
|
|
40
|
+
ensure
|
|
41
|
+
agent_loop&.finalize
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Emits a system message before each retry so the user sees
|
|
47
|
+
# "retrying..." instead of nothing.
|
|
48
|
+
def retry_job(options = {})
|
|
49
|
+
error = options[:error]
|
|
50
|
+
wait = options[:wait]
|
|
51
|
+
|
|
52
|
+
Events::Bus.emit(Events::SystemMessage.new(
|
|
53
|
+
content: "#{error.message} — retrying in #{wait.to_i}s...",
|
|
54
|
+
session_id: arguments.first
|
|
55
|
+
))
|
|
56
|
+
|
|
57
|
+
super
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
class CountEventTokensJob < ApplicationJob
|
|
7
7
|
queue_as :default
|
|
8
8
|
|
|
9
|
-
retry_on Providers::Anthropic::Error, wait: :
|
|
9
|
+
retry_on Providers::Anthropic::Error, wait: :polynomially_longer, attempts: 3
|
|
10
10
|
discard_on ActiveRecord::RecordNotFound
|
|
11
11
|
|
|
12
12
|
# @param event_id [Integer] the Event record to count tokens for
|
data/app/models/event.rb
CHANGED
|
@@ -22,6 +22,9 @@ class Event < ApplicationRecord
|
|
|
22
22
|
|
|
23
23
|
ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
|
|
24
24
|
|
|
25
|
+
# Heuristic: average bytes per token for English prose.
|
|
26
|
+
BYTES_PER_TOKEN = 4
|
|
27
|
+
|
|
25
28
|
belongs_to :session
|
|
26
29
|
|
|
27
30
|
validates :event_type, presence: true, inclusion: {in: TYPES}
|
|
@@ -56,6 +59,20 @@ class Event < ApplicationRecord
|
|
|
56
59
|
event_type.in?(CONTEXT_TYPES)
|
|
57
60
|
end
|
|
58
61
|
|
|
62
|
+
# Heuristic token estimate: ~4 bytes per token for English prose.
|
|
63
|
+
# Tool events are estimated from the full payload JSON since tool_input
|
|
64
|
+
# and tool metadata contribute to token count. Messages use content only.
|
|
65
|
+
#
|
|
66
|
+
# @return [Integer] estimated token count (at least 1)
|
|
67
|
+
def estimate_tokens
|
|
68
|
+
text = if event_type.in?(%w[tool_call tool_response])
|
|
69
|
+
payload.to_json
|
|
70
|
+
else
|
|
71
|
+
payload["content"].to_s
|
|
72
|
+
end
|
|
73
|
+
[(text.bytesize / BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
74
|
+
end
|
|
75
|
+
|
|
59
76
|
private
|
|
60
77
|
|
|
61
78
|
def schedule_token_count
|
data/app/models/session.rb
CHANGED
|
@@ -7,23 +7,29 @@ class Session < ApplicationRecord
|
|
|
7
7
|
# Claude Sonnet 4 context window minus system prompt reserve.
|
|
8
8
|
DEFAULT_TOKEN_BUDGET = 190_000
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
BYTES_PER_TOKEN = 4
|
|
10
|
+
VIEW_MODES = %w[basic verbose debug].freeze
|
|
12
11
|
|
|
13
12
|
has_many :events, -> { order(:id) }, dependent: :destroy
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
#
|
|
14
|
+
validates :view_mode, inclusion: {in: VIEW_MODES}
|
|
15
|
+
|
|
16
|
+
scope :recent, ->(limit = 10) { order(updated_at: :desc).limit(limit) }
|
|
17
|
+
|
|
18
|
+
# Cycles to the next view mode: basic → verbose → debug → basic.
|
|
20
19
|
#
|
|
20
|
+
# @return [String] the next view mode in the cycle
|
|
21
|
+
def next_view_mode
|
|
22
|
+
current_index = VIEW_MODES.index(view_mode) || 0
|
|
23
|
+
VIEW_MODES[(current_index + 1) % VIEW_MODES.size]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns the events currently visible in the LLM context window.
|
|
21
27
|
# Walks events newest-first and includes them until the token budget
|
|
22
28
|
# is exhausted. Events are full-size or excluded entirely.
|
|
23
29
|
#
|
|
24
30
|
# @param token_budget [Integer] maximum tokens to include (positive)
|
|
25
|
-
# @return [Array<
|
|
26
|
-
def
|
|
31
|
+
# @return [Array<Event>] chronologically ordered
|
|
32
|
+
def viewport_events(token_budget: DEFAULT_TOKEN_BUDGET)
|
|
27
33
|
selected = []
|
|
28
34
|
remaining = token_budget
|
|
29
35
|
|
|
@@ -35,7 +41,28 @@ class Session < ApplicationRecord
|
|
|
35
41
|
remaining -= cost
|
|
36
42
|
end
|
|
37
43
|
|
|
38
|
-
|
|
44
|
+
selected.reverse
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Returns the assembled system prompt for this session.
|
|
48
|
+
# The system prompt includes system instructions, goals, and memories.
|
|
49
|
+
# Currently a placeholder — these subsystems are not yet implemented.
|
|
50
|
+
#
|
|
51
|
+
# @return [String, nil] the system prompt text, or nil if not configured
|
|
52
|
+
def system_prompt
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Builds the message array expected by the Anthropic Messages API.
|
|
57
|
+
# Includes user/agent messages and tool call/response events in
|
|
58
|
+
# Anthropic's wire format. Consecutive tool_call events are grouped
|
|
59
|
+
# into a single assistant message; consecutive tool_response events
|
|
60
|
+
# are grouped into a single user message with tool_result blocks.
|
|
61
|
+
#
|
|
62
|
+
# @param token_budget [Integer] maximum tokens to include (positive)
|
|
63
|
+
# @return [Array<Hash>] Anthropic Messages API format
|
|
64
|
+
def messages_for_llm(token_budget: DEFAULT_TOKEN_BUDGET)
|
|
65
|
+
assemble_messages(viewport_events(token_budget: token_budget))
|
|
39
66
|
end
|
|
40
67
|
|
|
41
68
|
private
|
|
@@ -88,18 +115,12 @@ class Session < ApplicationRecord
|
|
|
88
115
|
}
|
|
89
116
|
end
|
|
90
117
|
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
# and tool metadata contribute to token count.
|
|
118
|
+
# Delegates to {Event#estimate_tokens} for events not yet counted
|
|
119
|
+
# by the background job.
|
|
94
120
|
#
|
|
95
121
|
# @param event [Event]
|
|
96
122
|
# @return [Integer] at least 1
|
|
97
123
|
def estimate_tokens(event)
|
|
98
|
-
|
|
99
|
-
event.payload.to_json
|
|
100
|
-
else
|
|
101
|
-
event.payload["content"].to_s
|
|
102
|
-
end
|
|
103
|
-
[(text.bytesize / BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
124
|
+
event.estimate_tokens
|
|
104
125
|
end
|
|
105
126
|
end
|
data/bin/jobs
ADDED
data/bin/rails
ADDED
data/bin/rake
ADDED
data/config/application.rb
CHANGED
|
@@ -6,7 +6,10 @@ require "rails"
|
|
|
6
6
|
require "active_model/railtie"
|
|
7
7
|
require "active_record/railtie"
|
|
8
8
|
require "active_job/railtie"
|
|
9
|
+
require "action_cable/engine"
|
|
9
10
|
require "rails/test_unit/railtie"
|
|
11
|
+
require "draper"
|
|
12
|
+
require "solid_cable"
|
|
10
13
|
require "solid_queue"
|
|
11
14
|
|
|
12
15
|
Bundler.require(*Rails.groups) if ENV.key?("BUNDLE_GEMFILE")
|
|
@@ -20,6 +23,8 @@ module Anima
|
|
|
20
23
|
config.active_job.queue_adapter = :solid_queue
|
|
21
24
|
config.solid_queue.connects_to = {database: {writing: :queue}}
|
|
22
25
|
|
|
26
|
+
config.action_cable.disable_request_forgery_protection = true
|
|
27
|
+
|
|
23
28
|
anima_home = Pathname.new(File.expand_path("~/.anima"))
|
|
24
29
|
|
|
25
30
|
config.paths["log"] = [anima_home.join("log", "#{Rails.env}.log").to_s]
|
data/config/cable.yml
ADDED
data/config/database.yml
CHANGED
|
@@ -13,6 +13,10 @@ development:
|
|
|
13
13
|
<<: *default
|
|
14
14
|
database: <%= File.join(anima_home, "db", "development_queue.sqlite3") %>
|
|
15
15
|
migrations_paths: db/queue_migrate
|
|
16
|
+
cable:
|
|
17
|
+
<<: *default
|
|
18
|
+
database: <%= File.join(anima_home, "db", "development_cable.sqlite3") %>
|
|
19
|
+
migrations_paths: db/cable_migrate
|
|
16
20
|
|
|
17
21
|
test:
|
|
18
22
|
primary:
|
|
@@ -22,6 +26,10 @@ test:
|
|
|
22
26
|
<<: *default
|
|
23
27
|
database: <%= File.join(anima_home, "db", "test_queue.sqlite3") %>
|
|
24
28
|
migrations_paths: db/queue_migrate
|
|
29
|
+
cable:
|
|
30
|
+
<<: *default
|
|
31
|
+
database: <%= File.join(anima_home, "db", "test_cable.sqlite3") %>
|
|
32
|
+
migrations_paths: db/cable_migrate
|
|
25
33
|
|
|
26
34
|
production:
|
|
27
35
|
primary:
|
|
@@ -31,3 +39,7 @@ production:
|
|
|
31
39
|
<<: *default
|
|
32
40
|
database: <%= File.join(anima_home, "db", "production_queue.sqlite3") %>
|
|
33
41
|
migrations_paths: db/queue_migrate
|
|
42
|
+
cable:
|
|
43
|
+
<<: *default
|
|
44
|
+
database: <%= File.join(anima_home, "db", "production_cable.sqlite3") %>
|
|
45
|
+
migrations_paths: db/cable_migrate
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Registers global EventBus subscribers at boot time.
|
|
4
|
+
# Subscribers registered here receive all events regardless of which
|
|
5
|
+
# process emitted them (brain server, background job, etc.).
|
|
6
|
+
Rails.application.config.after_initialize do
|
|
7
|
+
# Global persister handles events from all sessions (brain server, background jobs).
|
|
8
|
+
# Skipped in test — specs manage their own persisters for isolation.
|
|
9
|
+
Events::Bus.subscribe(Events::Subscribers::Persister.new) unless Rails.env.test?
|
|
10
|
+
Events::Bus.subscribe(Events::Subscribers::ActionCableBridge.instance)
|
|
11
|
+
end
|
data/config/puma.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Anima brain server — serves Action Cable WebSocket connections
|
|
4
|
+
# and health check endpoint. Port 42134 by default.
|
|
5
|
+
|
|
6
|
+
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
|
|
7
|
+
threads threads_count, threads_count
|
|
8
|
+
|
|
9
|
+
port ENV.fetch("PORT", 42134)
|
|
10
|
+
|
|
11
|
+
pidfile ENV.fetch("PIDFILE", File.expand_path("~/.anima/tmp/pids/puma.pid"))
|
|
12
|
+
|
|
13
|
+
plugin :tmp_restart
|
data/config/routes.rb
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
Rails.application.routes.draw do
|
|
4
|
+
mount ActionCable.server => "/cable"
|
|
5
|
+
get "up", to: "rails/health#show", as: :rails_health_check
|
|
6
|
+
|
|
7
|
+
namespace :api do
|
|
8
|
+
resources :sessions, only: [:create] do
|
|
9
|
+
get :current, on: :collection
|
|
10
|
+
end
|
|
11
|
+
end
|
|
4
12
|
end
|
data/config.ru
ADDED
data/db/cable_schema.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# This file is auto-generated from the current state of the database. Instead
|
|
2
|
+
# of editing this file, please use the migrations feature of Active Record to
|
|
3
|
+
# incrementally modify your database, and then regenerate this schema definition.
|
|
4
|
+
#
|
|
5
|
+
# This file is the source Rails uses to define your schema when running `bin/rails
|
|
6
|
+
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
|
7
|
+
# be faster and is potentially less error prone than running all of your
|
|
8
|
+
# migrations from scratch. Old migrations may fail to apply correctly if those
|
|
9
|
+
# migrations use external dependencies or application code.
|
|
10
|
+
#
|
|
11
|
+
# It's strongly recommended that you check this file into your version control system.
|
|
12
|
+
|
|
13
|
+
ActiveRecord::Schema[8.1].define(version: 1) do
|
|
14
|
+
create_table "solid_cable_messages", force: :cascade do |t|
|
|
15
|
+
t.binary "channel", limit: 1024, null: false
|
|
16
|
+
t.integer "channel_hash", limit: 8, null: false
|
|
17
|
+
t.datetime "created_at", null: false
|
|
18
|
+
t.binary "payload", limit: 536870912, null: false
|
|
19
|
+
t.index ["channel"], name: "index_solid_cable_messages_on_channel"
|
|
20
|
+
t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
|
|
21
|
+
t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
|
|
22
|
+
end
|
|
23
|
+
end
|