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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +18 -0
  3. data/CHANGELOG.md +36 -0
  4. data/Gemfile +17 -0
  5. data/Procfile +2 -0
  6. data/Procfile.dev +2 -0
  7. data/README.md +167 -22
  8. data/Rakefile +20 -5
  9. data/anima-core.gemspec +40 -0
  10. data/app/channels/application_cable/channel.rb +6 -0
  11. data/app/channels/application_cable/connection.rb +6 -0
  12. data/app/channels/session_channel.rb +126 -0
  13. data/app/controllers/api/sessions_controller.rb +25 -0
  14. data/app/controllers/application_controller.rb +4 -0
  15. data/app/jobs/agent_request_job.rb +59 -0
  16. data/app/jobs/application_job.rb +4 -0
  17. data/app/jobs/count_event_tokens_job.rb +28 -0
  18. data/app/models/application_record.rb +5 -0
  19. data/app/models/event.rb +64 -0
  20. data/app/models/session.rb +114 -0
  21. data/bin/jobs +6 -0
  22. data/bin/rails +6 -0
  23. data/bin/rake +6 -0
  24. data/config/application.rb +35 -0
  25. data/config/boot.rb +8 -0
  26. data/config/cable.yml +14 -0
  27. data/config/database.yml +45 -0
  28. data/config/environment.rb +5 -0
  29. data/config/environments/development.rb +8 -0
  30. data/config/environments/production.rb +8 -0
  31. data/config/environments/test.rb +9 -0
  32. data/config/initializers/event_subscribers.rb +11 -0
  33. data/config/initializers/inflections.rb +9 -0
  34. data/config/puma.rb +13 -0
  35. data/config/queue.yml +18 -0
  36. data/config/recurring.yml +15 -0
  37. data/config/routes.rb +12 -0
  38. data/config.ru +5 -0
  39. data/db/cable_schema.rb +11 -0
  40. data/db/migrate/.keep +0 -0
  41. data/db/migrate/20260308124202_create_sessions.rb +9 -0
  42. data/db/migrate/20260308124203_create_events.rb +18 -0
  43. data/db/migrate/20260308130000_add_event_indexes.rb +9 -0
  44. data/db/migrate/20260308140000_remove_position_from_events.rb +8 -0
  45. data/db/migrate/20260308150000_add_token_count_to_events.rb +7 -0
  46. data/db/migrate/20260308160000_add_tool_use_id_to_events.rb +8 -0
  47. data/db/queue_schema.rb +141 -0
  48. data/db/seeds.rb +1 -0
  49. data/exe/anima +6 -0
  50. data/lib/agent_loop.rb +97 -0
  51. data/lib/anima/cli.rb +110 -0
  52. data/lib/anima/installer.rb +119 -0
  53. data/lib/anima/version.rb +1 -1
  54. data/lib/anima.rb +5 -0
  55. data/lib/events/agent_message.rb +11 -0
  56. data/lib/events/base.rb +38 -0
  57. data/lib/events/bus.rb +39 -0
  58. data/lib/events/subscriber.rb +26 -0
  59. data/lib/events/subscribers/action_cable_bridge.rb +35 -0
  60. data/lib/events/subscribers/message_collector.rb +64 -0
  61. data/lib/events/subscribers/persister.rb +56 -0
  62. data/lib/events/system_message.rb +11 -0
  63. data/lib/events/tool_call.rb +29 -0
  64. data/lib/events/tool_response.rb +33 -0
  65. data/lib/events/user_message.rb +11 -0
  66. data/lib/llm/client.rb +161 -0
  67. data/lib/providers/anthropic.rb +173 -0
  68. data/lib/shell_session.rb +333 -0
  69. data/lib/tools/base.rb +58 -0
  70. data/lib/tools/bash.rb +53 -0
  71. data/lib/tools/registry.rb +60 -0
  72. data/lib/tools/web_get.rb +62 -0
  73. data/lib/tui/app.rb +239 -0
  74. data/lib/tui/cable_client.rb +377 -0
  75. data/lib/tui/message_store.rb +49 -0
  76. data/lib/tui/screens/anthropic.rb +25 -0
  77. data/lib/tui/screens/chat.rb +321 -0
  78. data/lib/tui/screens/settings.rb +52 -0
  79. metadata +203 -6
  80. data/BRAINSTORM.md +0 -466
@@ -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
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/anima/cli"
5
+
6
+ Anima::CLI.start(ARGV)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Anima
4
- VERSION = "0.0.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/anima.rb CHANGED
@@ -1,7 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
3
4
  require_relative "anima/version"
4
5
 
5
6
  module Anima
6
7
  class Error < StandardError; end
8
+
9
+ def self.gem_root
10
+ @gem_root ||= Pathname.new(File.expand_path("..", __dir__))
11
+ end
7
12
  end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ class AgentMessage < Base
5
+ TYPE = "agent_message"
6
+
7
+ def type
8
+ TYPE
9
+ end
10
+ end
11
+ end
@@ -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