anima-core 0.0.1 → 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/.reek.yml +18 -0
- data/CHANGELOG.md +36 -0
- data/Gemfile +17 -0
- data/Procfile +2 -0
- data/Procfile.dev +2 -0
- data/README.md +167 -22
- data/Rakefile +20 -5
- 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/application_job.rb +4 -0
- data/app/jobs/count_event_tokens_job.rb +28 -0
- data/app/models/application_record.rb +5 -0
- data/app/models/event.rb +64 -0
- data/app/models/session.rb +114 -0
- data/bin/jobs +6 -0
- data/bin/rails +6 -0
- data/bin/rake +6 -0
- data/config/application.rb +35 -0
- data/config/boot.rb +8 -0
- data/config/cable.yml +14 -0
- data/config/database.yml +45 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +8 -0
- data/config/environments/production.rb +8 -0
- data/config/environments/test.rb +9 -0
- data/config/initializers/event_subscribers.rb +11 -0
- data/config/initializers/inflections.rb +9 -0
- data/config/puma.rb +13 -0
- data/config/queue.yml +18 -0
- data/config/recurring.yml +15 -0
- data/config/routes.rb +12 -0
- data/config.ru +5 -0
- data/db/cable_schema.rb +11 -0
- data/db/migrate/.keep +0 -0
- data/db/migrate/20260308124202_create_sessions.rb +9 -0
- data/db/migrate/20260308124203_create_events.rb +18 -0
- data/db/migrate/20260308130000_add_event_indexes.rb +9 -0
- data/db/migrate/20260308140000_remove_position_from_events.rb +8 -0
- data/db/migrate/20260308150000_add_token_count_to_events.rb +7 -0
- data/db/migrate/20260308160000_add_tool_use_id_to_events.rb +8 -0
- data/db/queue_schema.rb +141 -0
- data/db/seeds.rb +1 -0
- data/exe/anima +6 -0
- data/lib/agent_loop.rb +97 -0
- data/lib/anima/cli.rb +110 -0
- data/lib/anima/installer.rb +119 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +5 -0
- data/lib/events/agent_message.rb +11 -0
- data/lib/events/base.rb +38 -0
- data/lib/events/bus.rb +39 -0
- data/lib/events/subscriber.rb +26 -0
- data/lib/events/subscribers/action_cable_bridge.rb +35 -0
- data/lib/events/subscribers/message_collector.rb +64 -0
- data/lib/events/subscribers/persister.rb +56 -0
- data/lib/events/system_message.rb +11 -0
- data/lib/events/tool_call.rb +29 -0
- data/lib/events/tool_response.rb +33 -0
- data/lib/events/user_message.rb +11 -0
- data/lib/llm/client.rb +161 -0
- data/lib/providers/anthropic.rb +173 -0
- data/lib/shell_session.rb +333 -0
- data/lib/tools/base.rb +58 -0
- data/lib/tools/bash.rb +53 -0
- data/lib/tools/registry.rb +60 -0
- data/lib/tools/web_get.rb +62 -0
- data/lib/tui/app.rb +239 -0
- data/lib/tui/cable_client.rb +377 -0
- data/lib/tui/message_store.rb +49 -0
- data/lib/tui/screens/anthropic.rb +25 -0
- data/lib/tui/screens/chat.rb +321 -0
- data/lib/tui/screens/settings.rb +52 -0
- metadata +203 -6
- data/BRAINSTORM.md +0 -466
data/db/queue_schema.rb
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
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_queue_blocked_executions", force: :cascade do |t|
|
|
15
|
+
t.string "concurrency_key", null: false
|
|
16
|
+
t.datetime "created_at", null: false
|
|
17
|
+
t.datetime "expires_at", null: false
|
|
18
|
+
t.bigint "job_id", null: false
|
|
19
|
+
t.integer "priority", default: 0, null: false
|
|
20
|
+
t.string "queue_name", null: false
|
|
21
|
+
t.index ["concurrency_key", "priority", "job_id"], name: "index_solid_queue_blocked_executions_for_release"
|
|
22
|
+
t.index ["expires_at", "concurrency_key"], name: "index_solid_queue_blocked_executions_for_maintenance"
|
|
23
|
+
t.index ["job_id"], name: "index_solid_queue_blocked_executions_on_job_id", unique: true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
create_table "solid_queue_claimed_executions", force: :cascade do |t|
|
|
27
|
+
t.datetime "created_at", null: false
|
|
28
|
+
t.bigint "job_id", null: false
|
|
29
|
+
t.bigint "process_id"
|
|
30
|
+
t.index ["job_id"], name: "index_solid_queue_claimed_executions_on_job_id", unique: true
|
|
31
|
+
t.index ["process_id", "job_id"], name: "index_solid_queue_claimed_executions_on_process_id_and_job_id"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
create_table "solid_queue_failed_executions", force: :cascade do |t|
|
|
35
|
+
t.datetime "created_at", null: false
|
|
36
|
+
t.text "error"
|
|
37
|
+
t.bigint "job_id", null: false
|
|
38
|
+
t.index ["job_id"], name: "index_solid_queue_failed_executions_on_job_id", unique: true
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
create_table "solid_queue_jobs", force: :cascade do |t|
|
|
42
|
+
t.string "active_job_id"
|
|
43
|
+
t.text "arguments"
|
|
44
|
+
t.string "class_name", null: false
|
|
45
|
+
t.string "concurrency_key"
|
|
46
|
+
t.datetime "created_at", null: false
|
|
47
|
+
t.datetime "finished_at"
|
|
48
|
+
t.integer "priority", default: 0, null: false
|
|
49
|
+
t.string "queue_name", null: false
|
|
50
|
+
t.datetime "scheduled_at"
|
|
51
|
+
t.datetime "updated_at", null: false
|
|
52
|
+
t.index ["active_job_id"], name: "index_solid_queue_jobs_on_active_job_id"
|
|
53
|
+
t.index ["class_name"], name: "index_solid_queue_jobs_on_class_name"
|
|
54
|
+
t.index ["finished_at"], name: "index_solid_queue_jobs_on_finished_at"
|
|
55
|
+
t.index ["queue_name", "finished_at"], name: "index_solid_queue_jobs_for_filtering"
|
|
56
|
+
t.index ["scheduled_at", "finished_at"], name: "index_solid_queue_jobs_for_alerting"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
create_table "solid_queue_pauses", force: :cascade do |t|
|
|
60
|
+
t.datetime "created_at", null: false
|
|
61
|
+
t.string "queue_name", null: false
|
|
62
|
+
t.index ["queue_name"], name: "index_solid_queue_pauses_on_queue_name", unique: true
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
create_table "solid_queue_processes", force: :cascade do |t|
|
|
66
|
+
t.datetime "created_at", null: false
|
|
67
|
+
t.string "hostname"
|
|
68
|
+
t.string "kind", null: false
|
|
69
|
+
t.datetime "last_heartbeat_at", null: false
|
|
70
|
+
t.text "metadata"
|
|
71
|
+
t.string "name", null: false
|
|
72
|
+
t.integer "pid", null: false
|
|
73
|
+
t.bigint "supervisor_id"
|
|
74
|
+
t.index ["last_heartbeat_at"], name: "index_solid_queue_processes_on_last_heartbeat_at"
|
|
75
|
+
t.index ["name", "supervisor_id"], name: "index_solid_queue_processes_on_name_and_supervisor_id", unique: true
|
|
76
|
+
t.index ["supervisor_id"], name: "index_solid_queue_processes_on_supervisor_id"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
create_table "solid_queue_ready_executions", force: :cascade do |t|
|
|
80
|
+
t.datetime "created_at", null: false
|
|
81
|
+
t.bigint "job_id", null: false
|
|
82
|
+
t.integer "priority", default: 0, null: false
|
|
83
|
+
t.string "queue_name", null: false
|
|
84
|
+
t.index ["job_id"], name: "index_solid_queue_ready_executions_on_job_id", unique: true
|
|
85
|
+
t.index ["priority", "job_id"], name: "index_solid_queue_poll_all"
|
|
86
|
+
t.index ["queue_name", "priority", "job_id"], name: "index_solid_queue_poll_by_queue"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
create_table "solid_queue_recurring_executions", force: :cascade do |t|
|
|
90
|
+
t.datetime "created_at", null: false
|
|
91
|
+
t.bigint "job_id", null: false
|
|
92
|
+
t.datetime "run_at", null: false
|
|
93
|
+
t.string "task_key", null: false
|
|
94
|
+
t.index ["job_id"], name: "index_solid_queue_recurring_executions_on_job_id", unique: true
|
|
95
|
+
t.index ["task_key", "run_at"], name: "index_solid_queue_recurring_executions_on_task_key_and_run_at", unique: true
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
create_table "solid_queue_recurring_tasks", force: :cascade do |t|
|
|
99
|
+
t.text "arguments"
|
|
100
|
+
t.string "class_name"
|
|
101
|
+
t.string "command", limit: 2048
|
|
102
|
+
t.datetime "created_at", null: false
|
|
103
|
+
t.text "description"
|
|
104
|
+
t.string "key", null: false
|
|
105
|
+
t.integer "priority", default: 0
|
|
106
|
+
t.string "queue_name"
|
|
107
|
+
t.string "schedule", null: false
|
|
108
|
+
t.boolean "static", default: true, null: false
|
|
109
|
+
t.datetime "updated_at", null: false
|
|
110
|
+
t.index ["key"], name: "index_solid_queue_recurring_tasks_on_key", unique: true
|
|
111
|
+
t.index ["static"], name: "index_solid_queue_recurring_tasks_on_static"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
create_table "solid_queue_scheduled_executions", force: :cascade do |t|
|
|
115
|
+
t.datetime "created_at", null: false
|
|
116
|
+
t.bigint "job_id", null: false
|
|
117
|
+
t.integer "priority", default: 0, null: false
|
|
118
|
+
t.string "queue_name", null: false
|
|
119
|
+
t.datetime "scheduled_at", null: false
|
|
120
|
+
t.index ["job_id"], name: "index_solid_queue_scheduled_executions_on_job_id", unique: true
|
|
121
|
+
t.index ["scheduled_at", "priority", "job_id"], name: "index_solid_queue_dispatch_all"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
create_table "solid_queue_semaphores", force: :cascade do |t|
|
|
125
|
+
t.datetime "created_at", null: false
|
|
126
|
+
t.datetime "expires_at", null: false
|
|
127
|
+
t.string "key", null: false
|
|
128
|
+
t.datetime "updated_at", null: false
|
|
129
|
+
t.integer "value", default: 1, null: false
|
|
130
|
+
t.index ["expires_at"], name: "index_solid_queue_semaphores_on_expires_at"
|
|
131
|
+
t.index ["key", "value"], name: "index_solid_queue_semaphores_on_key_and_value"
|
|
132
|
+
t.index ["key"], name: "index_solid_queue_semaphores_on_key", unique: true
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
|
136
|
+
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
|
137
|
+
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
|
138
|
+
add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
|
139
|
+
add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
|
140
|
+
add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs", column: "job_id", on_delete: :cascade
|
|
141
|
+
end
|
data/db/seeds.rb
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
data/exe/anima
ADDED
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
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require_relative "../anima"
|
|
5
|
+
|
|
6
|
+
module Anima
|
|
7
|
+
class CLI < Thor
|
|
8
|
+
VALID_ENVIRONMENTS = %w[development test production].freeze
|
|
9
|
+
DEFAULT_PORT = 42134
|
|
10
|
+
DEFAULT_HOST = "localhost:#{DEFAULT_PORT}"
|
|
11
|
+
|
|
12
|
+
def self.exit_on_failure?
|
|
13
|
+
true
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
desc "install", "Set up ~/.anima/ with databases, credentials, and systemd service"
|
|
17
|
+
def install
|
|
18
|
+
require_relative "installer"
|
|
19
|
+
Installer.new.run
|
|
20
|
+
end
|
|
21
|
+
|
|
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)"
|
|
27
|
+
def start
|
|
28
|
+
env = options[:environment] || ENV.fetch("RAILS_ENV", "development")
|
|
29
|
+
unless VALID_ENVIRONMENTS.include?(env)
|
|
30
|
+
say "Invalid environment: #{env}. Must be one of: #{VALID_ENVIRONMENTS.join(", ")}", :red
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
ENV["RAILS_ENV"] = env
|
|
35
|
+
|
|
36
|
+
unless File.directory?(File.expand_path("~/.anima"))
|
|
37
|
+
say "Anima is not installed. Run 'anima install' first.", :red
|
|
38
|
+
exit 1
|
|
39
|
+
end
|
|
40
|
+
|
|
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)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
desc "tui", "Launch the Anima terminal interface"
|
|
47
|
+
option :host, desc: "Brain server address (default: #{DEFAULT_HOST})"
|
|
48
|
+
def tui
|
|
49
|
+
require "ratatui_ruby"
|
|
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
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
desc "version", "Show version"
|
|
67
|
+
map %w[-v --version] => :version
|
|
68
|
+
def version
|
|
69
|
+
require_relative "version"
|
|
70
|
+
say "anima #{Anima::VERSION}"
|
|
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
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Anima
|
|
8
|
+
class Installer
|
|
9
|
+
DIRECTORIES = %w[
|
|
10
|
+
db
|
|
11
|
+
config/credentials
|
|
12
|
+
log
|
|
13
|
+
tmp
|
|
14
|
+
tmp/pids
|
|
15
|
+
tmp/cache
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
ANIMA_HOME = Pathname.new(File.expand_path("~/.anima")).freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :anima_home
|
|
21
|
+
|
|
22
|
+
def initialize(anima_home: ANIMA_HOME)
|
|
23
|
+
@anima_home = anima_home
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def run
|
|
27
|
+
say "Installing Anima to #{anima_home}..."
|
|
28
|
+
create_directories
|
|
29
|
+
create_config_file
|
|
30
|
+
generate_credentials
|
|
31
|
+
create_systemd_service
|
|
32
|
+
say "Installation complete. Brain is running. Connect with 'anima tui'."
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def create_directories
|
|
36
|
+
DIRECTORIES.each do |dir|
|
|
37
|
+
path = anima_home.join(dir)
|
|
38
|
+
next if path.directory?
|
|
39
|
+
|
|
40
|
+
FileUtils.mkdir_p(path)
|
|
41
|
+
say " created #{path}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def create_config_file
|
|
46
|
+
config_path = anima_home.join("config", "anima.yml")
|
|
47
|
+
return if config_path.exist?
|
|
48
|
+
|
|
49
|
+
config_path.write(<<~YAML)
|
|
50
|
+
# Anima configuration
|
|
51
|
+
# See https://github.com/hoblin/anima for documentation
|
|
52
|
+
YAML
|
|
53
|
+
say " created #{config_path}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def generate_credentials
|
|
57
|
+
require "active_support"
|
|
58
|
+
require "active_support/encrypted_configuration"
|
|
59
|
+
|
|
60
|
+
%w[production development test].each do |env|
|
|
61
|
+
content_path = anima_home.join("config", "credentials", "#{env}.yml.enc")
|
|
62
|
+
key_path = anima_home.join("config", "credentials", "#{env}.key")
|
|
63
|
+
|
|
64
|
+
next if key_path.exist? && content_path.exist?
|
|
65
|
+
|
|
66
|
+
key = ActiveSupport::EncryptedFile.generate_key
|
|
67
|
+
key_path.write(key)
|
|
68
|
+
File.chmod(0o600, key_path.to_s)
|
|
69
|
+
|
|
70
|
+
config = ActiveSupport::EncryptedConfiguration.new(
|
|
71
|
+
config_path: content_path.to_s,
|
|
72
|
+
key_path: key_path.to_s,
|
|
73
|
+
env_key: "RAILS_MASTER_KEY",
|
|
74
|
+
raise_if_missing_key: true
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
config.write("secret_key_base: #{SecureRandom.hex(64)}\n")
|
|
78
|
+
File.chmod(0o600, content_path.to_s)
|
|
79
|
+
say " created credentials for #{env}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def create_systemd_service
|
|
84
|
+
service_dir = Pathname.new(File.expand_path("~/.config/systemd/user"))
|
|
85
|
+
service_path = service_dir.join("anima.service")
|
|
86
|
+
|
|
87
|
+
return if service_path.exist?
|
|
88
|
+
|
|
89
|
+
FileUtils.mkdir_p(service_dir)
|
|
90
|
+
anima_bin = File.join(Gem.bindir, "anima")
|
|
91
|
+
|
|
92
|
+
service_path.write(<<~UNIT)
|
|
93
|
+
[Unit]
|
|
94
|
+
Description=Anima - Personal AI Agent
|
|
95
|
+
After=network.target
|
|
96
|
+
|
|
97
|
+
[Service]
|
|
98
|
+
Type=simple
|
|
99
|
+
ExecStart=#{anima_bin} start -e production
|
|
100
|
+
Restart=on-failure
|
|
101
|
+
RestartSec=5
|
|
102
|
+
|
|
103
|
+
[Install]
|
|
104
|
+
WantedBy=default.target
|
|
105
|
+
UNIT
|
|
106
|
+
|
|
107
|
+
say " created #{service_path}"
|
|
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"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def say(message)
|
|
116
|
+
$stdout.puts message
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
data/lib/anima/version.rb
CHANGED
data/lib/anima.rb
CHANGED
data/lib/events/base.rb
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Base class for all Anima events. Subclasses must implement #type
|
|
5
|
+
# returning a string identifier (e.g. "user_message").
|
|
6
|
+
#
|
|
7
|
+
# Events are POROs — they carry typed payloads through the event bus.
|
|
8
|
+
# Persistence is a separate concern handled by ActiveRecord models.
|
|
9
|
+
#
|
|
10
|
+
# @abstract Subclass and implement {#type}
|
|
11
|
+
class Base
|
|
12
|
+
attr_reader :content, :session_id, :timestamp
|
|
13
|
+
|
|
14
|
+
# @param content [String] event payload content
|
|
15
|
+
# @param session_id [String, nil] optional session identifier
|
|
16
|
+
def initialize(content:, session_id: nil)
|
|
17
|
+
@content = content
|
|
18
|
+
@session_id = session_id
|
|
19
|
+
@timestamp = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [String] event type identifier
|
|
23
|
+
# @raise [NotImplementedError] if subclass does not implement
|
|
24
|
+
def type
|
|
25
|
+
raise NotImplementedError, "#{self.class} must implement #type"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [String] namespaced event name for Rails.event (e.g. "anima.user_message")
|
|
29
|
+
def event_name
|
|
30
|
+
"#{Bus::NAMESPACE}.#{type}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @return [Hash] serialized event payload
|
|
34
|
+
def to_h
|
|
35
|
+
{type: type, content: content, session_id: session_id, timestamp: timestamp}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/events/bus.rb
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Central event bus built on Rails Structured Event Reporter.
|
|
5
|
+
# All Anima events flow through here — subsystems emit events and
|
|
6
|
+
# subscribers react independently without coupling.
|
|
7
|
+
#
|
|
8
|
+
# Subscribers must implement the {Subscriber} interface (#emit method).
|
|
9
|
+
# Rails.event wraps payloads: subscribers receive a Hash with :name,
|
|
10
|
+
# :payload (the event's to_h), and :timestamp keys.
|
|
11
|
+
#
|
|
12
|
+
# @example Emitting an event
|
|
13
|
+
# Events::Bus.emit(Events::UserMessage.new(content: "Hello"))
|
|
14
|
+
#
|
|
15
|
+
# @example Subscribing
|
|
16
|
+
# subscriber = MySubscriber.new # must implement #emit(event_hash)
|
|
17
|
+
# Events::Bus.subscribe(subscriber)
|
|
18
|
+
module Bus
|
|
19
|
+
NAMESPACE = "anima"
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
# @param event [Events::Base] the event to broadcast
|
|
23
|
+
def emit(event)
|
|
24
|
+
Rails.event.notify(event.event_name, event.to_h)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @param subscriber [#emit] object implementing the Subscriber interface
|
|
28
|
+
# @param filter [Proc] optional filter block passed to Rails.event
|
|
29
|
+
def subscribe(subscriber, &filter)
|
|
30
|
+
Rails.event.subscribe(subscriber, &filter)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param subscriber [#emit] previously subscribed object
|
|
34
|
+
def unsubscribe(subscriber)
|
|
35
|
+
Rails.event.unsubscribe(subscriber)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
# Interface for event bus subscribers. Include this module and implement
|
|
5
|
+
# #emit to receive Rails.event notifications.
|
|
6
|
+
#
|
|
7
|
+
# The #emit method receives a Hash from Rails Structured Event Reporter:
|
|
8
|
+
# { name: "anima.user_message",
|
|
9
|
+
# payload: { type: "user_message", content: "hello", ... },
|
|
10
|
+
# timestamp: <nanosecond Integer> }
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# class MySubscriber
|
|
14
|
+
# include Events::Subscriber
|
|
15
|
+
#
|
|
16
|
+
# def emit(event)
|
|
17
|
+
# content = event.dig(:payload, :content)
|
|
18
|
+
# # handle event...
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
module Subscriber
|
|
22
|
+
def emit(event)
|
|
23
|
+
raise NotImplementedError, "#{self.class} must implement #emit"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -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
|