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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +18 -0
  3. data/CHANGELOG.md +26 -0
  4. data/README.md +134 -19
  5. data/Rakefile +3 -0
  6. data/app/jobs/application_job.rb +4 -0
  7. data/app/jobs/count_event_tokens_job.rb +28 -0
  8. data/app/models/application_record.rb +5 -0
  9. data/app/models/event.rb +64 -0
  10. data/app/models/session.rb +105 -0
  11. data/config/application.rb +31 -0
  12. data/config/boot.rb +8 -0
  13. data/config/database.yml +33 -0
  14. data/config/environment.rb +5 -0
  15. data/config/environments/development.rb +8 -0
  16. data/config/environments/production.rb +8 -0
  17. data/config/environments/test.rb +9 -0
  18. data/config/initializers/inflections.rb +9 -0
  19. data/config/queue.yml +18 -0
  20. data/config/recurring.yml +15 -0
  21. data/config/routes.rb +4 -0
  22. data/db/migrate/.keep +0 -0
  23. data/db/migrate/20260308124202_create_sessions.rb +9 -0
  24. data/db/migrate/20260308124203_create_events.rb +18 -0
  25. data/db/migrate/20260308130000_add_event_indexes.rb +9 -0
  26. data/db/migrate/20260308140000_remove_position_from_events.rb +8 -0
  27. data/db/migrate/20260308150000_add_token_count_to_events.rb +7 -0
  28. data/db/migrate/20260308160000_add_tool_use_id_to_events.rb +8 -0
  29. data/db/queue_schema.rb +141 -0
  30. data/db/seeds.rb +1 -0
  31. data/exe/anima +6 -0
  32. data/lib/anima/cli.rb +55 -0
  33. data/lib/anima/installer.rb +118 -0
  34. data/lib/anima/version.rb +1 -1
  35. data/lib/anima.rb +4 -0
  36. data/lib/events/agent_message.rb +11 -0
  37. data/lib/events/base.rb +38 -0
  38. data/lib/events/bus.rb +39 -0
  39. data/lib/events/subscriber.rb +26 -0
  40. data/lib/events/subscribers/message_collector.rb +64 -0
  41. data/lib/events/subscribers/persister.rb +46 -0
  42. data/lib/events/system_message.rb +11 -0
  43. data/lib/events/tool_call.rb +29 -0
  44. data/lib/events/tool_response.rb +33 -0
  45. data/lib/events/user_message.rb +11 -0
  46. data/lib/llm/client.rb +161 -0
  47. data/lib/providers/anthropic.rb +164 -0
  48. data/lib/shell_session.rb +333 -0
  49. data/lib/tools/base.rb +58 -0
  50. data/lib/tools/bash.rb +53 -0
  51. data/lib/tools/registry.rb +60 -0
  52. data/lib/tools/web_get.rb +62 -0
  53. data/lib/tui/app.rb +181 -0
  54. data/lib/tui/screens/anthropic.rb +25 -0
  55. data/lib/tui/screens/chat.rb +210 -0
  56. data/lib/tui/screens/settings.rb +52 -0
  57. metadata +124 -4
  58. 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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class RemovePositionFromEvents < ActiveRecord::Migration[8.1]
4
+ def change
5
+ remove_index :events, [:session_id, :position]
6
+ remove_column :events, :position, :integer, null: false
7
+ end
8
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddTokenCountToEvents < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :events, :token_count, :integer, default: 0, null: false
6
+ end
7
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddToolUseIdToEvents < ActiveRecord::Migration[8.1]
4
+ def change
5
+ add_column :events, :tool_use_id, :string
6
+ add_index :events, :tool_use_id
7
+ end
8
+ end
@@ -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/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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Anima
4
- VERSION = "0.0.1"
4
+ VERSION = "0.1.0"
5
5
  end
data/lib/anima.rb CHANGED
@@ -4,4 +4,8 @@ require_relative "anima/version"
4
4
 
5
5
  module Anima
6
6
  class Error < StandardError; end
7
+
8
+ def self.gem_root
9
+ @gem_root ||= Pathname.new(File.expand_path("..", __dir__))
10
+ end
7
11
  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,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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ class SystemMessage < Base
5
+ TYPE = "system_message"
6
+
7
+ def type
8
+ TYPE
9
+ end
10
+ end
11
+ 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