anima-core 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -3
- data/Gemfile +17 -0
- data/Procfile +2 -0
- data/Procfile.dev +2 -0
- data/README.md +56 -26
- data/Rakefile +19 -7
- data/anima-core.gemspec +40 -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 +126 -0
- data/app/controllers/api/sessions_controller.rb +25 -0
- data/app/controllers/application_controller.rb +4 -0
- data/app/jobs/agent_request_job.rb +59 -0
- data/app/jobs/count_event_tokens_job.rb +1 -1
- data/app/models/session.rb +18 -9
- data/bin/jobs +6 -0
- data/bin/rails +6 -0
- data/bin/rake +6 -0
- data/config/application.rb +4 -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 +11 -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 +35 -0
- data/lib/events/subscribers/persister.rb +14 -4
- data/lib/providers/anthropic.rb +11 -2
- data/lib/tui/app.rb +71 -13
- data/lib/tui/cable_client.rb +377 -0
- data/lib/tui/message_store.rb +49 -0
- data/lib/tui/screens/chat.rb +179 -68
- metadata +80 -3
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,11 @@
|
|
|
1
|
+
ActiveRecord::Schema[8.1].define(version: 1) do
|
|
2
|
+
create_table "solid_cable_messages", force: :cascade do |t|
|
|
3
|
+
t.binary "channel", limit: 1024, null: false
|
|
4
|
+
t.binary "payload", limit: 536870912, null: false
|
|
5
|
+
t.datetime "created_at", null: false
|
|
6
|
+
t.integer "channel_hash", limit: 8, null: false
|
|
7
|
+
t.index ["channel"], name: "index_solid_cable_messages_on_channel"
|
|
8
|
+
t.index ["channel_hash"], name: "index_solid_cable_messages_on_channel_hash"
|
|
9
|
+
t.index ["created_at"], name: "index_solid_cable_messages_on_created_at"
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/agent_loop.rb
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Orchestrates the LLM agent loop: accepts user input, runs the tool-use
|
|
4
|
+
# cycle via {LLM::Client}, and emits events through {Events::Bus}.
|
|
5
|
+
#
|
|
6
|
+
# Extracted from {TUI::Screens::Chat} so the same agent logic can run from
|
|
7
|
+
# the TUI, a background job, or an Action Cable channel.
|
|
8
|
+
#
|
|
9
|
+
# @note Not thread-safe. Callers must serialize concurrent calls to {#process}
|
|
10
|
+
# (e.g. TUI uses a loading flag, future callers should use session-level locks).
|
|
11
|
+
#
|
|
12
|
+
# @example Basic usage
|
|
13
|
+
# loop = AgentLoop.new(session: session)
|
|
14
|
+
# loop.process("What files are in the current directory?")
|
|
15
|
+
# loop.finalize
|
|
16
|
+
#
|
|
17
|
+
# @example With dependency injection (testing)
|
|
18
|
+
# loop = AgentLoop.new(session: session, client: mock_client, registry: mock_registry)
|
|
19
|
+
# loop.process("hello")
|
|
20
|
+
#
|
|
21
|
+
# @example Background job usage (retry-safe)
|
|
22
|
+
# loop = AgentLoop.new(session: session)
|
|
23
|
+
# loop.run # processes persisted session messages without emitting UserMessage
|
|
24
|
+
# loop.finalize
|
|
25
|
+
class AgentLoop
|
|
26
|
+
# @return [Session] the conversation session this loop operates on
|
|
27
|
+
attr_reader :session
|
|
28
|
+
|
|
29
|
+
# @param session [Session] the conversation session
|
|
30
|
+
# @param shell_session [ShellSession, nil] injectable persistent shell;
|
|
31
|
+
# created automatically if not provided
|
|
32
|
+
# @param client [LLM::Client, nil] injectable LLM client;
|
|
33
|
+
# created lazily on first {#process} call if not provided
|
|
34
|
+
# @param registry [Tools::Registry, nil] injectable tool registry;
|
|
35
|
+
# built lazily on first {#process} call if not provided
|
|
36
|
+
def initialize(session:, shell_session: nil, client: nil, registry: nil)
|
|
37
|
+
@session = session
|
|
38
|
+
@shell_session = shell_session || ShellSession.new(session_id: session.id)
|
|
39
|
+
@client = client
|
|
40
|
+
@registry = registry
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Runs the agent loop for a single user input.
|
|
44
|
+
#
|
|
45
|
+
# Emits {Events::UserMessage} immediately, then delegates to {#run}.
|
|
46
|
+
# On error emits {Events::AgentMessage} with the error text.
|
|
47
|
+
#
|
|
48
|
+
# @param input [String] raw user input
|
|
49
|
+
# @return [String, nil] the agent's response text, or nil for blank input
|
|
50
|
+
def process(input)
|
|
51
|
+
text = input.to_s.strip
|
|
52
|
+
return if text.empty?
|
|
53
|
+
|
|
54
|
+
Events::Bus.emit(Events::UserMessage.new(content: text, session_id: @session.id))
|
|
55
|
+
run
|
|
56
|
+
rescue => error
|
|
57
|
+
error_message = "#{error.class}: #{error.message}"
|
|
58
|
+
Events::Bus.emit(Events::AgentMessage.new(content: error_message, session_id: @session.id))
|
|
59
|
+
error_message
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Runs the LLM tool-use loop on persisted session messages.
|
|
63
|
+
#
|
|
64
|
+
# Unlike {#process}, does not emit {Events::UserMessage} and lets errors
|
|
65
|
+
# propagate — designed for callers like {AgentRequestJob} that handle
|
|
66
|
+
# retries and need errors to bubble up.
|
|
67
|
+
#
|
|
68
|
+
# @return [String] the agent's response text
|
|
69
|
+
# @raise [Providers::Anthropic::TransientError] on retryable network/server errors
|
|
70
|
+
# @raise [Providers::Anthropic::AuthenticationError] on auth failures
|
|
71
|
+
def run
|
|
72
|
+
@client ||= LLM::Client.new
|
|
73
|
+
@registry ||= build_tool_registry
|
|
74
|
+
|
|
75
|
+
messages = @session.messages_for_llm
|
|
76
|
+
response = @client.chat_with_tools(messages, registry: @registry, session_id: @session.id)
|
|
77
|
+
Events::Bus.emit(Events::AgentMessage.new(content: response, session_id: @session.id))
|
|
78
|
+
response
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Clean up the underlying {ShellSession} PTY and resources.
|
|
82
|
+
# Safe to call multiple times — subsequent calls are no-ops.
|
|
83
|
+
def finalize
|
|
84
|
+
@shell_session&.finalize
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# Builds the default tool registry with all available tools.
|
|
90
|
+
# @return [Tools::Registry] registry with Bash and WebGet tools
|
|
91
|
+
def build_tool_registry
|
|
92
|
+
registry = Tools::Registry.new(context: {shell_session: @shell_session})
|
|
93
|
+
registry.register(Tools::WebGet)
|
|
94
|
+
registry.register(Tools::Bash)
|
|
95
|
+
registry
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/anima/cli.rb
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "thor"
|
|
4
|
+
require_relative "../anima"
|
|
4
5
|
|
|
5
6
|
module Anima
|
|
6
7
|
class CLI < Thor
|
|
7
8
|
VALID_ENVIRONMENTS = %w[development test production].freeze
|
|
9
|
+
DEFAULT_PORT = 42134
|
|
10
|
+
DEFAULT_HOST = "localhost:#{DEFAULT_PORT}"
|
|
8
11
|
|
|
9
12
|
def self.exit_on_failure?
|
|
10
13
|
true
|
|
@@ -16,10 +19,13 @@ module Anima
|
|
|
16
19
|
Installer.new.run
|
|
17
20
|
end
|
|
18
21
|
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
# Start the Anima brain server (Puma + Solid Queue) via Foreman.
|
|
23
|
+
# Environment precedence: -e flag > RAILS_ENV env var > "development".
|
|
24
|
+
# Requires prior installation (~/.anima must exist).
|
|
25
|
+
desc "start", "Start Anima (web + workers)"
|
|
26
|
+
option :environment, aliases: "-e", desc: "Rails environment (default: $RAILS_ENV or development)"
|
|
21
27
|
def start
|
|
22
|
-
env = options[:environment]
|
|
28
|
+
env = options[:environment] || ENV.fetch("RAILS_ENV", "development")
|
|
23
29
|
unless VALID_ENVIRONMENTS.include?(env)
|
|
24
30
|
say "Invalid environment: #{env}. Must be one of: #{VALID_ENVIRONMENTS.join(", ")}", :red
|
|
25
31
|
exit 1
|
|
@@ -32,17 +38,29 @@ module Anima
|
|
|
32
38
|
exit 1
|
|
33
39
|
end
|
|
34
40
|
|
|
35
|
-
|
|
36
|
-
|
|
41
|
+
gem_root = Anima.gem_root
|
|
42
|
+
system(gem_root.join("bin/rails").to_s, "db:prepare", chdir: gem_root.to_s) || abort("db:prepare failed")
|
|
43
|
+
exec("foreman", "start", "-f", gem_root.join("Procfile").to_s, "-p", DEFAULT_PORT.to_s, chdir: gem_root.to_s)
|
|
37
44
|
end
|
|
38
45
|
|
|
39
46
|
desc "tui", "Launch the Anima terminal interface"
|
|
47
|
+
option :host, desc: "Brain server address (default: #{DEFAULT_HOST})"
|
|
40
48
|
def tui
|
|
41
49
|
require "ratatui_ruby"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
require "net/http"
|
|
51
|
+
require "json"
|
|
52
|
+
require_relative "../tui/app"
|
|
53
|
+
|
|
54
|
+
host = options[:host] || DEFAULT_HOST
|
|
55
|
+
|
|
56
|
+
say "Connecting to brain at #{host}...", :cyan
|
|
57
|
+
session_id = fetch_current_session_with_retry(host)
|
|
58
|
+
say "Session ##{session_id} — starting TUI", :cyan
|
|
59
|
+
|
|
60
|
+
cable_client = TUI::CableClient.new(host: host, session_id: session_id)
|
|
61
|
+
cable_client.connect
|
|
62
|
+
|
|
63
|
+
TUI::App.new(cable_client: cable_client).run
|
|
46
64
|
end
|
|
47
65
|
|
|
48
66
|
desc "version", "Show version"
|
|
@@ -51,5 +69,42 @@ module Anima
|
|
|
51
69
|
require_relative "version"
|
|
52
70
|
say "anima #{Anima::VERSION}"
|
|
53
71
|
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
MAX_SESSION_FETCH_ATTEMPTS = 10
|
|
76
|
+
SESSION_FETCH_DELAY = 2 # seconds between retries
|
|
77
|
+
|
|
78
|
+
# Fetches the current session ID from the brain's REST API.
|
|
79
|
+
# Retries up to {MAX_SESSION_FETCH_ATTEMPTS} times if the brain is not running.
|
|
80
|
+
#
|
|
81
|
+
# @param host [String] brain server address
|
|
82
|
+
# @return [Integer] session ID
|
|
83
|
+
def fetch_current_session_with_retry(host)
|
|
84
|
+
attempts = 0
|
|
85
|
+
begin
|
|
86
|
+
fetch_current_session(host)
|
|
87
|
+
rescue Errno::ECONNREFUSED, Net::ReadTimeout, Net::OpenTimeout, SocketError => error
|
|
88
|
+
attempts += 1
|
|
89
|
+
if attempts >= MAX_SESSION_FETCH_ATTEMPTS
|
|
90
|
+
say "Cannot connect to brain after #{MAX_SESSION_FETCH_ATTEMPTS} attempts", :red
|
|
91
|
+
exit 1
|
|
92
|
+
end
|
|
93
|
+
say "Brain not available (#{error.class.name.split("::").last}). " \
|
|
94
|
+
"Retrying #{attempts}/#{MAX_SESSION_FETCH_ATTEMPTS}... (Ctrl+C to cancel)", :yellow
|
|
95
|
+
sleep SESSION_FETCH_DELAY
|
|
96
|
+
retry
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Fetches the current session ID from the brain's REST API.
|
|
101
|
+
# @param host [String] brain server address
|
|
102
|
+
# @return [Integer] session ID
|
|
103
|
+
# @raise [RuntimeError] if the brain returns an error response
|
|
104
|
+
def fetch_current_session(host)
|
|
105
|
+
uri = URI("http://#{host}/api/sessions/current")
|
|
106
|
+
body = Net::HTTP.get(uri)
|
|
107
|
+
JSON.parse(body)["id"]
|
|
108
|
+
end
|
|
54
109
|
end
|
|
55
110
|
end
|
data/lib/anima/installer.rb
CHANGED
|
@@ -29,7 +29,7 @@ module Anima
|
|
|
29
29
|
create_config_file
|
|
30
30
|
generate_credentials
|
|
31
31
|
create_systemd_service
|
|
32
|
-
say "Installation complete.
|
|
32
|
+
say "Installation complete. Brain is running. Connect with 'anima tui'."
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def create_directories
|
|
@@ -96,10 +96,9 @@ module Anima
|
|
|
96
96
|
|
|
97
97
|
[Service]
|
|
98
98
|
Type=simple
|
|
99
|
-
ExecStart=#{anima_bin} start
|
|
99
|
+
ExecStart=#{anima_bin} start -e production
|
|
100
100
|
Restart=on-failure
|
|
101
101
|
RestartSec=5
|
|
102
|
-
Environment=RAILS_ENV=production
|
|
103
102
|
|
|
104
103
|
[Install]
|
|
105
104
|
WantedBy=default.target
|
|
@@ -107,6 +106,8 @@ module Anima
|
|
|
107
106
|
|
|
108
107
|
say " created #{service_path}"
|
|
109
108
|
system("systemctl", "--user", "daemon-reload", err: File::NULL, out: File::NULL)
|
|
109
|
+
system("systemctl", "--user", "enable", "--now", "anima.service", err: File::NULL, out: File::NULL)
|
|
110
|
+
say " enabled and started anima.service"
|
|
110
111
|
end
|
|
111
112
|
|
|
112
113
|
private
|
data/lib/anima/version.rb
CHANGED
data/lib/anima.rb
CHANGED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Forwards EventBus events to Action Cable, bridging internal pub/sub
|
|
6
|
+
# to external WebSocket clients. Each event is broadcast to the
|
|
7
|
+
# session-specific stream (e.g. "session_42"), matching the stream
|
|
8
|
+
# name used by {SessionChannel}.
|
|
9
|
+
#
|
|
10
|
+
# Only events with a valid session_id are broadcast — events without
|
|
11
|
+
# one have no destination channel and are silently skipped.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# Events::Bus.subscribe(Events::Subscribers::ActionCableBridge.instance)
|
|
15
|
+
# # Now all events with session_id flow to "session_<id>" streams
|
|
16
|
+
class ActionCableBridge
|
|
17
|
+
include Events::Subscriber
|
|
18
|
+
include Singleton
|
|
19
|
+
|
|
20
|
+
# Receives a Rails.event notification hash and broadcasts the payload
|
|
21
|
+
# to the session's Action Cable stream.
|
|
22
|
+
#
|
|
23
|
+
# @param event [Hash] with :payload containing event data including :session_id
|
|
24
|
+
def emit(event)
|
|
25
|
+
payload = event[:payload]
|
|
26
|
+
return unless payload.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
session_id = payload[:session_id]
|
|
29
|
+
return if session_id.nil?
|
|
30
|
+
|
|
31
|
+
ActionCable.server.broadcast("session_#{session_id}", payload)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -5,16 +5,23 @@ module Events
|
|
|
5
5
|
# Persists all events to SQLite as they flow through the event bus.
|
|
6
6
|
# Each event is written as an Event record belonging to the active session.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
8
|
+
# When initialized with a specific session, all events are saved to that
|
|
9
|
+
# session. When initialized without one (global mode), the session is
|
|
10
|
+
# looked up from the event's session_id payload field.
|
|
11
|
+
#
|
|
12
|
+
# @example Session-scoped
|
|
10
13
|
# persister = Events::Subscribers::Persister.new(session)
|
|
11
14
|
# Events::Bus.subscribe(persister)
|
|
15
|
+
#
|
|
16
|
+
# @example Global (persists events for any session)
|
|
17
|
+
# persister = Events::Subscribers::Persister.new
|
|
18
|
+
# Events::Bus.subscribe(persister)
|
|
12
19
|
class Persister
|
|
13
20
|
include Events::Subscriber
|
|
14
21
|
|
|
15
22
|
attr_reader :session
|
|
16
23
|
|
|
17
|
-
def initialize(session)
|
|
24
|
+
def initialize(session = nil)
|
|
18
25
|
@session = session
|
|
19
26
|
@mutex = Mutex.new
|
|
20
27
|
end
|
|
@@ -28,8 +35,11 @@ module Events
|
|
|
28
35
|
event_type = payload[:type]
|
|
29
36
|
return if event_type.nil?
|
|
30
37
|
|
|
38
|
+
target_session = @session || Session.find_by(id: payload[:session_id])
|
|
39
|
+
return unless target_session
|
|
40
|
+
|
|
31
41
|
@mutex.synchronize do
|
|
32
|
-
|
|
42
|
+
target_session.events.create!(
|
|
33
43
|
event_type: event_type,
|
|
34
44
|
payload: payload,
|
|
35
45
|
tool_use_id: payload[:tool_use_id],
|
data/lib/providers/anthropic.rb
CHANGED
|
@@ -19,6 +19,11 @@ module Providers
|
|
|
19
19
|
class AuthenticationError < Error; end
|
|
20
20
|
class TokenFormatError < Error; end
|
|
21
21
|
|
|
22
|
+
# Transient errors that may succeed on retry (network issues, rate limits, server errors).
|
|
23
|
+
class TransientError < Error; end
|
|
24
|
+
class RateLimitError < TransientError; end
|
|
25
|
+
class ServerError < TransientError; end
|
|
26
|
+
|
|
22
27
|
class << self
|
|
23
28
|
def validate!
|
|
24
29
|
token = fetch_token
|
|
@@ -75,6 +80,8 @@ module Providers
|
|
|
75
80
|
)
|
|
76
81
|
|
|
77
82
|
handle_response(response)
|
|
83
|
+
rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => e
|
|
84
|
+
raise TransientError, "#{e.class}: #{e.message}"
|
|
78
85
|
end
|
|
79
86
|
|
|
80
87
|
# Count tokens in a message payload without creating a message.
|
|
@@ -96,6 +103,8 @@ module Providers
|
|
|
96
103
|
|
|
97
104
|
result = handle_response(response)
|
|
98
105
|
result["input_tokens"]
|
|
106
|
+
rescue Errno::ECONNRESET, Net::ReadTimeout, Net::OpenTimeout, SocketError, EOFError => e
|
|
107
|
+
raise TransientError, "#{e.class}: #{e.message}"
|
|
99
108
|
end
|
|
100
109
|
|
|
101
110
|
def validate_credentials!
|
|
@@ -147,9 +156,9 @@ module Providers
|
|
|
147
156
|
raise AuthenticationError,
|
|
148
157
|
"Forbidden (403): #{error_message(response)}"
|
|
149
158
|
when 429
|
|
150
|
-
raise
|
|
159
|
+
raise RateLimitError, "Rate limit exceeded: #{error_message(response)}"
|
|
151
160
|
when 500..599
|
|
152
|
-
raise
|
|
161
|
+
raise ServerError, "Anthropic server error (#{response.code}): #{response.message}"
|
|
153
162
|
else
|
|
154
163
|
raise Error, "Unexpected response (#{response.code}): #{response.message}"
|
|
155
164
|
end
|
data/lib/tui/app.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "cable_client"
|
|
4
|
+
require_relative "message_store"
|
|
3
5
|
require_relative "screens/chat"
|
|
4
6
|
require_relative "screens/settings"
|
|
5
7
|
require_relative "screens/anthropic"
|
|
@@ -19,13 +21,24 @@ module TUI
|
|
|
19
21
|
|
|
20
22
|
SIDEBAR_WIDTH = 28
|
|
21
23
|
|
|
24
|
+
# Connection status display styles
|
|
25
|
+
STATUS_STYLES = {
|
|
26
|
+
disconnected: {label: " DISCONNECTED ", fg: "white", bg: "red"},
|
|
27
|
+
connecting: {label: " CONNECTING ", fg: "black", bg: "yellow"},
|
|
28
|
+
connected: {label: " CONNECTED ", fg: "black", bg: "yellow"},
|
|
29
|
+
subscribed: {label: " CONNECTED ", fg: "black", bg: "green"},
|
|
30
|
+
reconnecting: {label: " RECONNECTING ", fg: "black", bg: "yellow"}
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
22
33
|
attr_reader :current_screen, :command_mode
|
|
23
34
|
|
|
24
|
-
|
|
35
|
+
# @param cable_client [TUI::CableClient] WebSocket client connected to the brain
|
|
36
|
+
def initialize(cable_client:)
|
|
37
|
+
@cable_client = cable_client
|
|
25
38
|
@current_screen = :chat
|
|
26
39
|
@command_mode = false
|
|
27
40
|
@screens = {
|
|
28
|
-
chat: Screens::Chat.new,
|
|
41
|
+
chat: Screens::Chat.new(cable_client: cable_client),
|
|
29
42
|
settings: Screens::Settings.new,
|
|
30
43
|
anthropic: Screens::Anthropic.new
|
|
31
44
|
}
|
|
@@ -36,9 +49,13 @@ module TUI
|
|
|
36
49
|
loop do
|
|
37
50
|
tui.draw { |frame| render(frame, tui) }
|
|
38
51
|
|
|
39
|
-
|
|
52
|
+
event = tui.poll_event(timeout: 0.1)
|
|
53
|
+
next if event.nil? || event.none?
|
|
54
|
+
break if handle_event(event) == :quit
|
|
40
55
|
end
|
|
41
56
|
end
|
|
57
|
+
ensure
|
|
58
|
+
@cable_client.disconnect
|
|
42
59
|
end
|
|
43
60
|
|
|
44
61
|
private
|
|
@@ -89,16 +106,29 @@ module TUI
|
|
|
89
106
|
end
|
|
90
107
|
|
|
91
108
|
def render_info(frame, area, tui)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
tui.
|
|
98
|
-
|
|
109
|
+
session = @screens[:chat].session_info
|
|
110
|
+
lines = [
|
|
111
|
+
tui.line(spans: [
|
|
112
|
+
tui.span(content: "Anima v#{Anima::VERSION}", style: tui.style(fg: "white"))
|
|
113
|
+
]),
|
|
114
|
+
tui.line(spans: [tui.span(content: "")]),
|
|
115
|
+
tui.line(spans: [
|
|
116
|
+
tui.span(content: "Session ", style: tui.style(fg: "dark_gray")),
|
|
117
|
+
tui.span(content: "##{session[:id]}", style: tui.style(fg: "cyan", modifiers: [:bold]))
|
|
118
|
+
]),
|
|
119
|
+
tui.line(spans: [
|
|
120
|
+
tui.span(content: "Messages ", style: tui.style(fg: "dark_gray")),
|
|
121
|
+
tui.span(content: session[:message_count].to_s, style: tui.style(fg: "cyan"))
|
|
122
|
+
]),
|
|
123
|
+
tui.line(spans: [tui.span(content: "")]),
|
|
124
|
+
tui.line(spans: [
|
|
125
|
+
tui.span(content: "Ctrl+a", style: tui.style(fg: "cyan", modifiers: [:bold])),
|
|
126
|
+
tui.span(content: " command mode", style: tui.style(fg: "dark_gray"))
|
|
127
|
+
])
|
|
128
|
+
]
|
|
99
129
|
|
|
100
130
|
info = tui.paragraph(
|
|
101
|
-
text:
|
|
131
|
+
text: lines,
|
|
102
132
|
block: tui.block(
|
|
103
133
|
title: "Info",
|
|
104
134
|
borders: [:all],
|
|
@@ -118,10 +148,28 @@ module TUI
|
|
|
118
148
|
tui.span(content: " NORMAL ", style: tui.style(fg: "black", bg: "cyan", modifiers: [:bold]))
|
|
119
149
|
end
|
|
120
150
|
|
|
121
|
-
|
|
151
|
+
conn_span = connection_status_span(tui)
|
|
152
|
+
|
|
153
|
+
widget = tui.paragraph(text: tui.line(spans: [mode_span, conn_span]))
|
|
122
154
|
frame.render_widget(widget, area)
|
|
123
155
|
end
|
|
124
156
|
|
|
157
|
+
def connection_status_span(tui)
|
|
158
|
+
cable_status = @cable_client.status
|
|
159
|
+
|
|
160
|
+
if cable_status == :reconnecting
|
|
161
|
+
attempt = @cable_client.reconnect_attempt
|
|
162
|
+
max = CableClient::MAX_RECONNECT_ATTEMPTS
|
|
163
|
+
label = " RECONNECTING (#{attempt}/#{max}) "
|
|
164
|
+
style = STATUS_STYLES[:reconnecting]
|
|
165
|
+
else
|
|
166
|
+
style = STATUS_STYLES.fetch(cable_status, STATUS_STYLES[:disconnected])
|
|
167
|
+
label = style[:label]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
tui.span(content: label, style: tui.style(fg: style[:fg], bg: style[:bg], modifiers: [:bold]))
|
|
171
|
+
end
|
|
172
|
+
|
|
125
173
|
def chat_loading?
|
|
126
174
|
@screens[:chat].loading?
|
|
127
175
|
end
|
|
@@ -157,6 +205,11 @@ module TUI
|
|
|
157
205
|
end
|
|
158
206
|
|
|
159
207
|
def handle_normal_mode(event)
|
|
208
|
+
if event.mouse?
|
|
209
|
+
delegate_to_screen(event)
|
|
210
|
+
return nil
|
|
211
|
+
end
|
|
212
|
+
|
|
160
213
|
return nil unless event.key?
|
|
161
214
|
|
|
162
215
|
if ctrl_a?(event)
|
|
@@ -169,9 +222,14 @@ module TUI
|
|
|
169
222
|
return nil
|
|
170
223
|
end
|
|
171
224
|
|
|
225
|
+
delegate_to_screen(event)
|
|
226
|
+
nil
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Forwards an event to the active screen for handling
|
|
230
|
+
def delegate_to_screen(event)
|
|
172
231
|
screen = @screens[@current_screen]
|
|
173
232
|
screen.handle_event(event) if screen.respond_to?(:handle_event)
|
|
174
|
-
nil
|
|
175
233
|
end
|
|
176
234
|
|
|
177
235
|
def ctrl_a?(event)
|