anima-core 0.0.1 → 0.1.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 +26 -0
- data/README.md +134 -19
- data/Rakefile +3 -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 +105 -0
- data/config/application.rb +31 -0
- data/config/boot.rb +8 -0
- data/config/database.yml +33 -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/inflections.rb +9 -0
- data/config/queue.yml +18 -0
- data/config/recurring.yml +15 -0
- data/config/routes.rb +4 -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/anima/cli.rb +55 -0
- data/lib/anima/installer.rb +118 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +4 -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/message_collector.rb +64 -0
- data/lib/events/subscribers/persister.rb +46 -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 +164 -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 +181 -0
- data/lib/tui/screens/anthropic.rb +25 -0
- data/lib/tui/screens/chat.rb +210 -0
- data/lib/tui/screens/settings.rb +52 -0
- metadata +124 -4
- data/BRAINSTORM.md +0 -466
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class CreateEvents < ActiveRecord::Migration[8.1]
|
|
4
|
+
def change
|
|
5
|
+
create_table :events do |t|
|
|
6
|
+
t.references :session, null: false, foreign_key: true
|
|
7
|
+
t.string :event_type, null: false
|
|
8
|
+
t.json :payload, null: false, default: {}
|
|
9
|
+
t.integer :position, null: false
|
|
10
|
+
t.integer :timestamp, limit: 8, null: false
|
|
11
|
+
|
|
12
|
+
t.timestamps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
add_index :events, [:session_id, :position]
|
|
16
|
+
add_index :events, :event_type
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class AddEventIndexes < ActiveRecord::Migration[8.1]
|
|
4
|
+
def change
|
|
5
|
+
remove_index :events, [:session_id, :position]
|
|
6
|
+
add_index :events, [:session_id, :position], unique: true
|
|
7
|
+
add_index :events, [:session_id, :event_type]
|
|
8
|
+
end
|
|
9
|
+
end
|
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/anima/cli.rb
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Anima
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
VALID_ENVIRONMENTS = %w[development test production].freeze
|
|
8
|
+
|
|
9
|
+
def self.exit_on_failure?
|
|
10
|
+
true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
desc "install", "Set up ~/.anima/ with databases, credentials, and systemd service"
|
|
14
|
+
def install
|
|
15
|
+
require_relative "installer"
|
|
16
|
+
Installer.new.run
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
desc "start", "Boot Anima (runs pending migrations, then exits)"
|
|
20
|
+
option :environment, aliases: "-e", default: "development", desc: "Rails environment"
|
|
21
|
+
def start
|
|
22
|
+
env = options[:environment]
|
|
23
|
+
unless VALID_ENVIRONMENTS.include?(env)
|
|
24
|
+
say "Invalid environment: #{env}. Must be one of: #{VALID_ENVIRONMENTS.join(", ")}", :red
|
|
25
|
+
exit 1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
ENV["RAILS_ENV"] = env
|
|
29
|
+
|
|
30
|
+
unless File.directory?(File.expand_path("~/.anima"))
|
|
31
|
+
say "Anima is not installed. Run 'anima install' first.", :red
|
|
32
|
+
exit 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
system(Anima.gem_root.join("bin/rails").to_s, "db:prepare") || abort("db:prepare failed")
|
|
36
|
+
say "Anima booted successfully (#{env}).", :green
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
desc "tui", "Launch the Anima terminal interface"
|
|
40
|
+
def tui
|
|
41
|
+
require "ratatui_ruby"
|
|
42
|
+
ENV["RAILS_ENV"] ||= "development"
|
|
43
|
+
require_relative "../../config/environment"
|
|
44
|
+
ActiveRecord::Tasks::DatabaseTasks.prepare_all
|
|
45
|
+
TUI::App.new.run
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
desc "version", "Show version"
|
|
49
|
+
map %w[-v --version] => :version
|
|
50
|
+
def version
|
|
51
|
+
require_relative "version"
|
|
52
|
+
say "anima #{Anima::VERSION}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
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. Run 'anima start' to begin."
|
|
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
|
|
100
|
+
Restart=on-failure
|
|
101
|
+
RestartSec=5
|
|
102
|
+
Environment=RAILS_ENV=production
|
|
103
|
+
|
|
104
|
+
[Install]
|
|
105
|
+
WantedBy=default.target
|
|
106
|
+
UNIT
|
|
107
|
+
|
|
108
|
+
say " created #{service_path}"
|
|
109
|
+
system("systemctl", "--user", "daemon-reload", err: File::NULL, out: File::NULL)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private
|
|
113
|
+
|
|
114
|
+
def say(message)
|
|
115
|
+
$stdout.puts message
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
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,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Collects chat-displayable events in-memory for the current session.
|
|
6
|
+
# Provides the message list that the TUI renders and the LLM client consumes.
|
|
7
|
+
#
|
|
8
|
+
# Only user_message and agent_message events are collected — system_message,
|
|
9
|
+
# tool_call, and tool_response are internal and not part of the chat display.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# collector = Events::Subscribers::MessageCollector.new
|
|
13
|
+
# Events::Bus.subscribe(collector)
|
|
14
|
+
# collector.messages # => [{role: "user", content: "hi"}, ...]
|
|
15
|
+
class MessageCollector
|
|
16
|
+
include Events::Subscriber
|
|
17
|
+
|
|
18
|
+
DISPLAYABLE_TYPES = %w[user_message agent_message].freeze
|
|
19
|
+
|
|
20
|
+
# Maps event types to LLM-compatible role identifiers
|
|
21
|
+
ROLE_MAP = {
|
|
22
|
+
"user_message" => "user",
|
|
23
|
+
"agent_message" => "assistant"
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
def initialize
|
|
27
|
+
@messages = []
|
|
28
|
+
@mutex = Mutex.new
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Array<Hash>] thread-safe copy of collected messages
|
|
32
|
+
def messages
|
|
33
|
+
@mutex.synchronize { @messages.dup }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Receives a Rails.event notification hash.
|
|
37
|
+
# @param event [Hash] with :payload containing :type and :content keys
|
|
38
|
+
def emit(event)
|
|
39
|
+
type = event.dig(:payload, :type)
|
|
40
|
+
return unless DISPLAYABLE_TYPES.include?(type)
|
|
41
|
+
|
|
42
|
+
content = event.dig(:payload, :content)
|
|
43
|
+
return if content.nil?
|
|
44
|
+
|
|
45
|
+
@mutex.synchronize do
|
|
46
|
+
@messages << {
|
|
47
|
+
role: ROLE_MAP.fetch(type),
|
|
48
|
+
content: content
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Directly push a pre-built message hash (used for loading persisted events).
|
|
54
|
+
# @param message [Hash] with :role and :content keys
|
|
55
|
+
def messages_push(message)
|
|
56
|
+
@mutex.synchronize { @messages << message }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def clear
|
|
60
|
+
@mutex.synchronize { @messages = [] }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
module Subscribers
|
|
5
|
+
# Persists all events to SQLite as they flow through the event bus.
|
|
6
|
+
# Each event is written as an Event record belonging to the active session.
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# session = Session.create!
|
|
10
|
+
# persister = Events::Subscribers::Persister.new(session)
|
|
11
|
+
# Events::Bus.subscribe(persister)
|
|
12
|
+
class Persister
|
|
13
|
+
include Events::Subscriber
|
|
14
|
+
|
|
15
|
+
attr_reader :session
|
|
16
|
+
|
|
17
|
+
def initialize(session)
|
|
18
|
+
@session = session
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Receives a Rails.event notification hash and persists it.
|
|
23
|
+
# @param event [Hash] with :payload containing event data
|
|
24
|
+
def emit(event)
|
|
25
|
+
payload = event[:payload]
|
|
26
|
+
return unless payload.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
event_type = payload[:type]
|
|
29
|
+
return if event_type.nil?
|
|
30
|
+
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
@session.events.create!(
|
|
33
|
+
event_type: event_type,
|
|
34
|
+
payload: payload,
|
|
35
|
+
tool_use_id: payload[:tool_use_id],
|
|
36
|
+
timestamp: payload[:timestamp] || Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def session=(new_session)
|
|
42
|
+
@mutex.synchronize { @session = new_session }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Events
|
|
4
|
+
class ToolCall < Base
|
|
5
|
+
TYPE = "tool_call"
|
|
6
|
+
|
|
7
|
+
attr_reader :tool_name, :tool_input, :tool_use_id
|
|
8
|
+
|
|
9
|
+
# @param content [String] human-readable description of the tool call
|
|
10
|
+
# @param tool_name [String] registered tool name (e.g. "web_get")
|
|
11
|
+
# @param tool_input [Hash] arguments passed to the tool
|
|
12
|
+
# @param tool_use_id [String] Anthropic-assigned ID for correlating call/result
|
|
13
|
+
# @param session_id [String, nil] optional session identifier
|
|
14
|
+
def initialize(content:, tool_name:, tool_input: {}, tool_use_id: nil, session_id: nil)
|
|
15
|
+
super(content: content, session_id: session_id)
|
|
16
|
+
@tool_name = tool_name
|
|
17
|
+
@tool_input = tool_input
|
|
18
|
+
@tool_use_id = tool_use_id
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def type
|
|
22
|
+
TYPE
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def to_h
|
|
26
|
+
super.merge(tool_name: tool_name, tool_input: tool_input, tool_use_id: tool_use_id)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|