anima-core 1.3.0 → 1.5.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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +23 -26
  3. data/README.md +118 -104
  4. data/agents/thoughts-analyzer.md +12 -7
  5. data/anima-core.gemspec +1 -0
  6. data/app/channels/session_channel.rb +38 -58
  7. data/app/decorators/agent_message_decorator.rb +7 -2
  8. data/app/decorators/message_decorator.rb +31 -100
  9. data/app/decorators/pending_from_melete_decorator.rb +36 -0
  10. data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
  11. data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
  12. data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
  13. data/app/decorators/pending_from_mneme_decorator.rb +44 -0
  14. data/app/decorators/pending_message_decorator.rb +94 -0
  15. data/app/decorators/pending_subagent_decorator.rb +46 -0
  16. data/app/decorators/pending_tool_response_decorator.rb +51 -0
  17. data/app/decorators/pending_user_message_decorator.rb +22 -0
  18. data/app/decorators/system_message_decorator.rb +5 -0
  19. data/app/decorators/tool_call_decorator.rb +16 -5
  20. data/app/decorators/tool_response_decorator.rb +2 -2
  21. data/app/decorators/user_message_decorator.rb +7 -2
  22. data/app/jobs/count_tokens_job.rb +23 -0
  23. data/app/jobs/drain_job.rb +169 -0
  24. data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
  25. data/app/jobs/melete_enrichment_job.rb +48 -0
  26. data/app/jobs/mneme_enrichment_job.rb +46 -0
  27. data/app/jobs/tool_execution_job.rb +87 -0
  28. data/app/models/concerns/token_estimation.rb +54 -0
  29. data/app/models/goal.rb +23 -11
  30. data/app/models/message.rb +46 -48
  31. data/app/models/pending_message.rb +407 -12
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +660 -566
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +157 -0
  36. data/bin/release +212 -0
  37. data/bin/with-llms +20 -0
  38. data/config/application.rb +1 -0
  39. data/config/database.yml +1 -0
  40. data/config/initializers/event_subscribers.rb +71 -4
  41. data/config/initializers/inflections.rb +3 -1
  42. data/db/cable_structure.sql +9 -0
  43. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  44. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  45. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  46. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  47. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  48. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  49. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  50. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  51. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  52. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  53. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  54. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  55. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  56. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  57. data/db/queue_structure.sql +61 -0
  58. data/db/structure.sql +133 -0
  59. data/lib/agents/registry.rb +1 -1
  60. data/lib/anima/cli.rb +41 -13
  61. data/lib/anima/installer.rb +13 -0
  62. data/lib/anima/settings.rb +16 -36
  63. data/lib/anima/version.rb +1 -1
  64. data/lib/events/authentication_required.rb +24 -0
  65. data/lib/events/bounce_back.rb +4 -4
  66. data/lib/events/eviction_completed.rb +28 -0
  67. data/lib/events/goal_created.rb +28 -0
  68. data/lib/events/goal_updated.rb +32 -0
  69. data/lib/events/llm_responded.rb +35 -0
  70. data/lib/events/message_created.rb +27 -0
  71. data/lib/events/message_updated.rb +25 -0
  72. data/lib/events/session_state_changed.rb +30 -0
  73. data/lib/events/skill_activated.rb +28 -0
  74. data/lib/events/start_melete.rb +36 -0
  75. data/lib/events/start_mneme.rb +33 -0
  76. data/lib/events/start_processing.rb +32 -0
  77. data/lib/events/subagent_evicted.rb +31 -0
  78. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  79. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  80. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  81. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  82. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  83. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  84. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  85. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  86. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  87. data/lib/events/subscribers/persister.rb +8 -9
  88. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  89. data/lib/events/subscribers/subagent_message_router.rb +28 -34
  90. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  91. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  92. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  93. data/lib/events/tool_executed.rb +34 -0
  94. data/lib/events/workflow_activated.rb +27 -0
  95. data/lib/llm/client.rb +46 -199
  96. data/lib/mcp/client_manager.rb +41 -46
  97. data/lib/mcp/stdio_transport.rb +9 -5
  98. data/lib/{analytical_brain → melete}/runner.rb +73 -68
  99. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
  100. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
  101. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  102. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
  103. data/lib/melete/tools/goal_messaging.rb +29 -0
  104. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
  105. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  106. data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
  107. data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
  108. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  109. data/lib/mneme/base_runner.rb +121 -0
  110. data/lib/mneme/l2_runner.rb +14 -20
  111. data/lib/mneme/recall_runner.rb +132 -0
  112. data/lib/mneme/runner.rb +123 -165
  113. data/lib/mneme/search.rb +104 -62
  114. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  115. data/lib/mneme/tools/save_snapshot.rb +2 -10
  116. data/lib/mneme/tools/surface_memory.rb +89 -0
  117. data/lib/mneme.rb +11 -5
  118. data/lib/providers/anthropic.rb +112 -7
  119. data/lib/shell_session.rb +290 -432
  120. data/lib/skills/definition.rb +2 -2
  121. data/lib/skills/registry.rb +1 -1
  122. data/lib/tools/base.rb +16 -1
  123. data/lib/tools/bash.rb +25 -55
  124. data/lib/tools/edit.rb +2 -0
  125. data/lib/tools/mark_goal_completed.rb +4 -5
  126. data/lib/tools/read.rb +2 -0
  127. data/lib/tools/registry.rb +85 -4
  128. data/lib/tools/response_truncator.rb +1 -1
  129. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  130. data/lib/tools/spawn_specialist.rb +22 -14
  131. data/lib/tools/spawn_subagent.rb +30 -20
  132. data/lib/tools/subagent_prompts.rb +17 -19
  133. data/lib/tools/think.rb +1 -1
  134. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  135. data/lib/tools/write.rb +2 -0
  136. data/lib/tui/app.rb +393 -149
  137. data/lib/tui/braille_spinner.rb +7 -7
  138. data/lib/tui/cable_client.rb +9 -16
  139. data/lib/tui/decorators/base_decorator.rb +47 -6
  140. data/lib/tui/decorators/bash_decorator.rb +1 -1
  141. data/lib/tui/decorators/edit_decorator.rb +4 -2
  142. data/lib/tui/decorators/read_decorator.rb +4 -2
  143. data/lib/tui/decorators/think_decorator.rb +2 -2
  144. data/lib/tui/decorators/web_get_decorator.rb +1 -1
  145. data/lib/tui/decorators/write_decorator.rb +4 -2
  146. data/lib/tui/flash.rb +19 -14
  147. data/lib/tui/formatting.rb +20 -9
  148. data/lib/tui/input_buffer.rb +6 -6
  149. data/lib/tui/message_store.rb +165 -28
  150. data/lib/tui/performance_logger.rb +2 -3
  151. data/lib/tui/screens/chat.rb +149 -79
  152. data/lib/tui/settings.rb +93 -0
  153. data/lib/workflows/definition.rb +3 -3
  154. data/lib/workflows/registry.rb +1 -1
  155. data/skills/github.md +38 -0
  156. data/templates/config.toml +16 -32
  157. data/templates/tui.toml +209 -0
  158. data/workflows/review_pr.md +18 -14
  159. metadata +98 -29
  160. data/app/jobs/agent_request_job.rb +0 -199
  161. data/app/jobs/analytical_brain_job.rb +0 -33
  162. data/app/jobs/count_message_tokens_job.rb +0 -39
  163. data/app/jobs/passive_recall_job.rb +0 -29
  164. data/app/models/concerns/message/broadcasting.rb +0 -85
  165. data/config/initializers/fts5_schema_dump.rb +0 -21
  166. data/lib/agent_loop.rb +0 -186
  167. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
  168. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
  169. data/lib/environment_probe.rb +0 -232
  170. data/lib/events/agent_message.rb +0 -11
  171. data/lib/events/subscribers/message_collector.rb +0 -64
  172. data/lib/events/tool_call.rb +0 -31
  173. data/lib/events/tool_response.rb +0 -33
  174. data/lib/mneme/compressed_viewport.rb +0 -200
  175. data/lib/mneme/passive_recall.rb +0 -69
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ # Emitted after a Message record is updated and committed.
5
+ # Used by subscribers that need to react to message changes
6
+ # (e.g. broadcasting updated token counts to WebSocket clients).
7
+ class MessageUpdated
8
+ TYPE = "message.updated"
9
+
10
+ attr_reader :message
11
+
12
+ # @param message [Message] the updated message record
13
+ def initialize(message)
14
+ @message = message
15
+ end
16
+
17
+ def event_name
18
+ "#{Bus::NAMESPACE}.#{TYPE}"
19
+ end
20
+
21
+ def to_h
22
+ {type: TYPE, message:}
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ # Emitted when a session's transport-level state changes. Carries the
5
+ # AASM state after a transition (+"idle"+/+"awaiting"+/+"executing"+)
6
+ # or a transient UI signal (+"interrupting"+).
7
+ #
8
+ # Subscribers broadcast the state over ActionCable so the TUI spinner
9
+ # and sub-agent HUD update in sync.
10
+ class SessionStateChanged
11
+ TYPE = "session.state_changed"
12
+
13
+ attr_reader :session_id, :state
14
+
15
+ # @param session_id [Integer] the session the state change belongs to
16
+ # @param state [String] transport state name
17
+ def initialize(session_id:, state:)
18
+ @session_id = session_id
19
+ @state = state
20
+ end
21
+
22
+ def event_name
23
+ "#{Bus::NAMESPACE}.#{TYPE}"
24
+ end
25
+
26
+ def to_h
27
+ {type: TYPE, session_id: session_id, state: state}
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ # Emitted after {Session#activate_skill} enqueues a skill's phantom
5
+ # pair. Subscribers rebroadcast the session's active skills/workflow
6
+ # so the HUD reflects the new activation immediately (before the
7
+ # pending message even promotes).
8
+ class SkillActivated
9
+ TYPE = "skill.activated"
10
+
11
+ attr_reader :session_id, :skill_name
12
+
13
+ # @param session_id [Integer] the session the skill was activated on
14
+ # @param skill_name [String] canonical skill name
15
+ def initialize(session_id:, skill_name:)
16
+ @session_id = session_id
17
+ @skill_name = skill_name
18
+ end
19
+
20
+ def event_name
21
+ "#{Bus::NAMESPACE}.#{TYPE}"
22
+ end
23
+
24
+ def to_h
25
+ {type: TYPE, session_id:, skill_name:}
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ # Emitted when a +user_message+ PendingMessage lands on an idle session.
5
+ # Melete subscribes via {Events::Subscribers::MeleteKickoff} and runs
6
+ # its enrichment loop — activating skills, reading workflows, refining
7
+ # goals, renaming the session — then either:
8
+ #
9
+ # * emits {Events::StartMneme} when a goal changed during the run, so
10
+ # Mneme can recall against the fresh goal set, or
11
+ # * emits {Events::StartProcessing} when goals were untouched, skipping
12
+ # Mneme entirely (no new search seed to recall against).
13
+ #
14
+ # First stage of the +start_melete → (start_mneme) → start_processing+
15
+ # chain that orchestrates context enrichment before the LLM is called.
16
+ class StartMelete
17
+ TYPE = "session.start_melete"
18
+
19
+ attr_reader :session_id, :pending_message_id
20
+
21
+ # @param session_id [Integer] session whose enrichment chain should continue
22
+ # @param pending_message_id [Integer, nil] the PendingMessage that triggered the chain
23
+ def initialize(session_id:, pending_message_id: nil)
24
+ @session_id = session_id
25
+ @pending_message_id = pending_message_id
26
+ end
27
+
28
+ def event_name
29
+ "#{Bus::NAMESPACE}.#{TYPE}"
30
+ end
31
+
32
+ def to_h
33
+ {type: TYPE, session_id:, pending_message_id:}
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ # Emitted by {MeleteEnrichmentJob} when goals changed during the Melete
5
+ # run, signalling that Mneme should recall against the fresh goal set.
6
+ # Mneme subscribes via {Events::Subscribers::MnemeKickoff}, performs
7
+ # associative recall, enqueues its memories as background PendingMessages,
8
+ # and emits {Events::StartProcessing} to continue the drain.
9
+ #
10
+ # Second stage of the +start_melete → (start_mneme) → start_processing+
11
+ # chain. Conditional — when goals are untouched the pipeline jumps
12
+ # straight from {Events::StartMelete} to {Events::StartProcessing}.
13
+ class StartMneme
14
+ TYPE = "session.start_mneme"
15
+
16
+ attr_reader :session_id, :pending_message_id
17
+
18
+ # @param session_id [Integer] session whose drain pipeline should start
19
+ # @param pending_message_id [Integer] the PendingMessage that triggered the chain
20
+ def initialize(session_id:, pending_message_id:)
21
+ @session_id = session_id
22
+ @pending_message_id = pending_message_id
23
+ end
24
+
25
+ def event_name
26
+ "#{Bus::NAMESPACE}.#{TYPE}"
27
+ end
28
+
29
+ def to_h
30
+ {type: TYPE, session_id:, pending_message_id:}
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ # Emitted when an active PendingMessage lands on an idle session and does
5
+ # not require the Melete/Mneme enrichment pipeline (tool calls, tool
6
+ # responses, sub-agent replies), or when {MeleteEnrichmentJob} finishes
7
+ # without a goal change, or when {MnemeEnrichmentJob} finishes recall.
8
+ # The drain loop subscribes and begins processing the mailbox.
9
+ #
10
+ # Final stage of the +start_melete → (start_mneme) → start_processing+
11
+ # chain.
12
+ class StartProcessing
13
+ TYPE = "session.start_processing"
14
+
15
+ attr_reader :session_id, :pending_message_id
16
+
17
+ # @param session_id [Integer] session whose drain loop should start
18
+ # @param pending_message_id [Integer, nil] the PendingMessage that triggered the chain, if any
19
+ def initialize(session_id:, pending_message_id: nil)
20
+ @session_id = session_id
21
+ @pending_message_id = pending_message_id
22
+ end
23
+
24
+ def event_name
25
+ "#{Bus::NAMESPACE}.#{TYPE}"
26
+ end
27
+
28
+ def to_h
29
+ {type: TYPE, session_id:, pending_message_id:}
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ # Emitted when {Mneme::Runner} advances the boundary past every remaining
5
+ # trace of a sub-agent — the spawn pair plus every +from_{nickname}+
6
+ # phantom pair. Subscribers broadcast the removal so clients drop the
7
+ # entry from the HUD panel.
8
+ #
9
+ # +session_id+ is the parent session (HUD owner), +child_id+ is the
10
+ # sub-agent session whose traces just aged out.
11
+ class SubagentEvicted
12
+ TYPE = "subagent.evicted"
13
+
14
+ attr_reader :session_id, :child_id
15
+
16
+ # @param session_id [Integer] parent session whose HUD should drop the entry
17
+ # @param child_id [Integer] sub-agent session whose traces were evicted
18
+ def initialize(session_id:, child_id:)
19
+ @session_id = session_id
20
+ @child_id = child_id
21
+ end
22
+
23
+ def event_name
24
+ "#{Bus::NAMESPACE}.#{TYPE}"
25
+ end
26
+
27
+ def to_h
28
+ {type: TYPE, session_id:, child_id:}
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Rebroadcasts the session's active skills and workflow whenever the
6
+ # set can change: skill activation, workflow activation, or Mneme
7
+ # eviction. Same handler, three triggers — each event carries a
8
+ # +session_id+ and the broadcaster reads live state off the session.
9
+ #
10
+ # @example Registering at boot
11
+ # trigger = ->(event) {
12
+ # %w[anima.skill.activated anima.workflow.activated anima.eviction.completed]
13
+ # .include?(event[:name])
14
+ # }
15
+ # Events::Bus.subscribe(Events::Subscribers::ActiveStateBroadcaster.new, &trigger)
16
+ class ActiveStateBroadcaster
17
+ include Events::Subscriber
18
+
19
+ # @param event [Hash] Rails.event notification hash
20
+ def emit(event)
21
+ session_id = event.dig(:payload, :session_id)
22
+ session = Session.find_by(id: session_id)
23
+ session&.broadcast_active_state!
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Reacts to {Events::AuthenticationRequired} by surfacing the provider
6
+ # rejection to the operator. Emits a +system_message+ into the
7
+ # conversation (so the failure lives in history) and broadcasts an
8
+ # +authentication_required+ frame on the session's ActionCable stream
9
+ # (so the TUI can prompt for a new token).
10
+ #
11
+ # Follows the same shape as {SessionStateBroadcaster}: jobs emit
12
+ # typed events, broadcasters own the ActionCable side.
13
+ class AuthenticationBroadcaster
14
+ include Events::Subscriber
15
+
16
+ # @param event [Hash] Rails.event notification hash
17
+ def emit(event)
18
+ payload = event[:payload]
19
+ session_id = payload[:session_id]
20
+ message = payload[:content]
21
+
22
+ Events::Bus.emit(Events::SystemMessage.new(
23
+ content: "Authentication failed: #{message}",
24
+ session_id: session_id
25
+ ))
26
+
27
+ ActionCable.server.broadcast(
28
+ "session_#{session_id}",
29
+ {"action" => "authentication_required", "message" => message}
30
+ )
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Entry subscriber for the drain loop. On {Events::StartProcessing},
6
+ # enqueues {DrainJob} — the actual work (session claim, PM promotion,
7
+ # LLM call) happens in the job so the emitter's thread isn't blocked.
8
+ class DrainKickoff
9
+ include Events::Subscriber
10
+
11
+ # @param event [Hash] Rails.event notification hash
12
+ def emit(event)
13
+ session_id = event[:payload][:session_id]
14
+ return unless session_id
15
+
16
+ DrainJob.perform_later(session_id)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Broadcasts eviction cutoff to connected WebSocket clients after Mneme
6
+ # advances the boundary. Clients drop all messages above the cutoff
7
+ # (id <= evict_above_id) — older messages at the top of the chat view.
8
+ #
9
+ # @example Registering at boot
10
+ # Events::Bus.subscribe(Events::Subscribers::EvictionBroadcaster.new) { |event|
11
+ # event[:name] == "anima.eviction.completed"
12
+ # }
13
+ class EvictionBroadcaster
14
+ include Events::Subscriber
15
+
16
+ # @param event [Hash] Rails.event notification hash
17
+ def emit(event)
18
+ payload = event[:payload]
19
+ ActionCable.server.broadcast(
20
+ "session_#{payload[:session_id]}",
21
+ {"action" => "eviction", "evict_above_id" => payload[:evict_above_id]}
22
+ )
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Handles the aftermath of a single LLM round-trip emitted via
6
+ # {Events::LLMResponded}. Persists the assistant's output as Message
7
+ # records, transitions the session state, and — when the response
8
+ # includes a +tool_use+ block — queues {ToolExecutionJob} for each
9
+ # tool.
10
+ #
11
+ # This is where session state moves away from +:awaiting+: either
12
+ # {Session#response_complete!} on a text-only response, or
13
+ # {Session#tool_received!} before dispatching tool work. The drain
14
+ # job itself never transitions state past +:awaiting+ — that is this
15
+ # subscriber's responsibility, per the SOLID rule that event
16
+ # emission is the final act of a piece.
17
+ class LLMResponseHandler
18
+ include Events::Subscriber
19
+
20
+ # @param event [Hash] Rails.event notification hash
21
+ def emit(event)
22
+ payload = event[:payload]
23
+ session = Session.find(payload[:session_id])
24
+
25
+ response = payload[:response] || {}
26
+ api_metrics = payload[:api_metrics]
27
+
28
+ tool_uses = normalize_tool_uses(response)
29
+ text = extract_text(response)
30
+
31
+ persist_agent_message(session, text, api_metrics) if text.present?
32
+ tool_uses.each { |tool_use| persist_tool_call(session, tool_use) }
33
+
34
+ if tool_uses.any?
35
+ session.tool_received! if session.may_tool_received?
36
+ dispatch_tool_executions(session, tool_uses)
37
+ elsif session.may_response_complete?
38
+ session.response_complete!
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def content_blocks(response)
45
+ response["content"] || response[:content] || []
46
+ end
47
+
48
+ def block_type(block)
49
+ block["type"] || block[:type]
50
+ end
51
+
52
+ # Returns tool_use blocks with a guaranteed +id+. Generates a UUID
53
+ # once when the provider omits one so persistence and dispatch see
54
+ # the same id — a missing match breaks tool_use/tool_result
55
+ # pairing in the Anthropic conversation.
56
+ def normalize_tool_uses(response)
57
+ content_blocks(response).filter_map do |block|
58
+ next unless block_type(block) == "tool_use"
59
+
60
+ {
61
+ "id" => block["id"] || block[:id] || SecureRandom.uuid,
62
+ "name" => block["name"] || block[:name],
63
+ "input" => block["input"] || block[:input] || {}
64
+ }
65
+ end
66
+ end
67
+
68
+ def extract_text(response)
69
+ content_blocks(response)
70
+ .select { |block| block_type(block) == "text" }
71
+ .map { |block| block["text"] || block[:text] }
72
+ .join
73
+ end
74
+
75
+ def persist_agent_message(session, text, api_metrics)
76
+ session.messages.create!(
77
+ message_type: "agent_message",
78
+ payload: {"type" => "agent_message", "content" => text, "session_id" => session.id},
79
+ timestamp: Time.current.to_ns,
80
+ api_metrics: api_metrics
81
+ )
82
+ end
83
+
84
+ def persist_tool_call(session, tool_use)
85
+ session.messages.create!(
86
+ message_type: "tool_call",
87
+ tool_use_id: tool_use["id"],
88
+ payload: {
89
+ "type" => "tool_call",
90
+ "tool_name" => tool_use["name"],
91
+ "tool_use_id" => tool_use["id"],
92
+ "tool_input" => tool_use["input"],
93
+ "content" => "Calling #{tool_use["name"]}"
94
+ },
95
+ timestamp: Time.current.to_ns
96
+ )
97
+ end
98
+
99
+ def dispatch_tool_executions(session, tool_uses)
100
+ tool_uses.each do |tool_use|
101
+ ToolExecutionJob.perform_later(
102
+ session.id,
103
+ tool_use_id: tool_use["id"],
104
+ tool_name: tool_use["name"],
105
+ tool_input: tool_use["input"]
106
+ )
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Entry subscriber for the Melete stage of the drain pipeline. On
6
+ # {Events::StartMelete}, enqueues {MeleteEnrichmentJob} to run
7
+ # skill/goal/workflow preparation asynchronously.
8
+ class MeleteKickoff
9
+ include Events::Subscriber
10
+
11
+ # @param event [Hash] Rails.event notification hash
12
+ def emit(event)
13
+ payload = event[:payload]
14
+ session_id = payload[:session_id]
15
+ return unless session_id
16
+
17
+ MeleteEnrichmentJob.perform_later(
18
+ session_id,
19
+ pending_message_id: payload[:pending_message_id]
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Broadcasts message lifecycle events to connected WebSocket clients
6
+ # via ActionCable. Subscribes to {Events::MessageCreated} and
7
+ # {Events::MessageUpdated} events.
8
+ #
9
+ # @example Registering at boot
10
+ # Events::Bus.subscribe(Events::Subscribers::MessageBroadcaster.new) { |event|
11
+ # event[:name].start_with?("anima.message.")
12
+ # }
13
+ class MessageBroadcaster
14
+ include Events::Subscriber
15
+
16
+ ACTION_MAP = {
17
+ Events::MessageCreated::TYPE => "create",
18
+ Events::MessageUpdated::TYPE => "update"
19
+ }.freeze
20
+
21
+ # @param event [Hash] Rails.event notification hash
22
+ def emit(event)
23
+ message = event[:payload][:message]
24
+ action = ACTION_MAP.fetch(event[:payload][:type])
25
+ session = message.session
26
+ broadcast_payload = message.payload.merge("id" => message.id, "action" => action)
27
+ broadcast_payload["api_metrics"] = message.api_metrics if message.api_metrics.present?
28
+ broadcast_payload["rendered"] = {session.view_mode => message.decorate.render(session.view_mode)}
29
+
30
+ ActionCable.server.broadcast("session_#{message.session_id}", broadcast_payload)
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Entry subscriber for the Mneme stage of the drain pipeline. On
6
+ # {Events::StartMneme}, enqueues {MnemeEnrichmentJob} to run
7
+ # associative recall asynchronously.
8
+ class MnemeKickoff
9
+ include Events::Subscriber
10
+
11
+ # @param event [Hash] Rails.event notification hash
12
+ def emit(event)
13
+ payload = event[:payload]
14
+ session_id = payload[:session_id]
15
+ return unless session_id
16
+
17
+ MnemeEnrichmentJob.perform_later(
18
+ session_id,
19
+ pending_message_id: payload[:pending_message_id]
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Checks whether Mneme should run after each persisted message.
6
+ # Subscribes to {Events::MessageCreated} events.
7
+ #
8
+ # @example Registering at boot
9
+ # Events::Bus.subscribe(Events::Subscribers::MnemeScheduler.new) { |event|
10
+ # event[:name] == "anima.message.created"
11
+ # }
12
+ class MnemeScheduler
13
+ include Events::Subscriber
14
+
15
+ # @param event [Hash] Rails.event notification hash
16
+ def emit(event)
17
+ event[:payload][:message].session.schedule_mneme!
18
+ end
19
+ end
20
+ end
21
+ end
@@ -9,10 +9,9 @@ module Events
9
9
  # session. When initialized without one (global mode), the session is
10
10
  # looked up from the event's session_id payload field.
11
11
  #
12
- # User messages are NOT persisted here — they are created directly by
13
- # their callers ({SessionChannel#speak}, {AgentLoop#run}) so the
14
- # message ID is available for bounce-back cleanup. Pending user
15
- # messages live in the {PendingMessage} table, outside the event bus.
12
+ # User messages are NOT persisted here — {DrainJob} promotes them
13
+ # from {PendingMessage} into the Message stream as part of the drain
14
+ # cycle so bounce-back semantics stay close to the promotion.
16
15
  #
17
16
  # @example Session-scoped
18
17
  # persister = Events::Subscribers::Persister.new(session)
@@ -33,10 +32,9 @@ module Events
33
32
 
34
33
  # Receives a Rails.event notification hash and persists it.
35
34
  #
36
- # Skips user messages — those are persisted by their callers
37
- # ({SessionChannel#speak}, {AgentLoop#run}). Also skips event
38
- # types not in {Message::TYPES} (transient events like
39
- # {Events::BounceBack}).
35
+ # Skips user messages — those are promoted from PendingMessage by
36
+ # {DrainJob}. Also skips event types not in {Message::TYPES}
37
+ # (transient events like {Events::BounceBack}).
40
38
  #
41
39
  # @param event [Hash] with :payload containing event data
42
40
  def emit(event)
@@ -56,7 +54,8 @@ module Events
56
54
  message_type: event_type,
57
55
  payload: payload,
58
56
  tool_use_id: payload[:tool_use_id],
59
- timestamp: payload[:timestamp] || Time.current.to_ns
57
+ timestamp: payload[:timestamp] || Time.current.to_ns,
58
+ api_metrics: payload[:api_metrics]
60
59
  )
61
60
  end
62
61
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Broadcasts session state over ActionCable in response to
6
+ # {Events::SessionStateChanged}. Sends +session_state+ to the session
7
+ # stream and, for sub-agents, +child_state+ to the parent stream so the
8
+ # HUD updates without a full children refresh.
9
+ #
10
+ # @example Registering at boot
11
+ # trigger = ->(event) { event[:name] == "anima.session.state_changed" }
12
+ # Events::Bus.subscribe(Events::Subscribers::SessionStateBroadcaster.new, &trigger)
13
+ class SessionStateBroadcaster
14
+ include Events::Subscriber
15
+
16
+ # @param event [Hash] Rails.event notification hash
17
+ def emit(event)
18
+ payload = event[:payload]
19
+ session_id = payload[:session_id]
20
+ state = payload[:state]
21
+
22
+ action_payload = {"action" => "session_state", "state" => state, "session_id" => session_id}
23
+ ActionCable.server.broadcast("session_#{session_id}", action_payload)
24
+
25
+ parent_id = Session.where(id: session_id).pick(:parent_session_id)
26
+ return unless parent_id
27
+
28
+ parent_payload = action_payload.merge("action" => "child_state", "child_id" => session_id)
29
+ ActionCable.server.broadcast("session_#{parent_id}", parent_payload)
30
+ end
31
+ end
32
+ end
33
+ end