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
@@ -7,10 +7,55 @@
7
7
  # Sessions form a hierarchy: a main session can spawn child sessions
8
8
  # (sub-agents) that inherit the parent's viewport context at fork time.
9
9
  class Session < ApplicationRecord
10
+ include AASM
11
+
10
12
  class MissingSoulError < StandardError; end
11
13
 
12
14
  VIEW_MODES = %w[basic verbose debug].freeze
13
15
 
16
+ # Non-default AASM options:
17
+ # - +whiny_transitions: false+ makes invalid transitions return +false+
18
+ # instead of raising. {DrainJob} depends on this: +start_processing!+
19
+ # returning +false+ signals that the session is busy (+:awaiting+) or
20
+ # that the current tool round is still incomplete, so the current
21
+ # invocation exits silently.
22
+ # - +no_direct_assignment: true+ blocks +session.aasm_state = ...+, forcing
23
+ # every transition through a named event so guards always run.
24
+ # - +requires_lock: true+ wraps each transition in a pessimistic row lock
25
+ # (+SELECT FOR UPDATE+ on PostgreSQL, +BEGIN IMMEDIATE+ on SQLite) so
26
+ # two workers racing +start_processing!+ on a parallel tool-use turn
27
+ # can't both succeed — the loser reads the updated +:awaiting+ state
28
+ # and bails silently.
29
+ aasm whiny_transitions: false, no_direct_assignment: true, requires_lock: true do
30
+ after_all_events :emit_state_change
31
+ after_all_events :clear_interrupt_flag_if_idle
32
+ after_all_events :wake_drain_pipeline_if_pending
33
+
34
+ state :idle, initial: true
35
+ state :awaiting
36
+ state :executing
37
+
38
+ # Drain claim. Two transitions, one event:
39
+ # - From +:idle+, the session is fresh — claim unconditionally.
40
+ # - From +:executing+, only claim once every +tool_use_id+ from the
41
+ # latest assistant turn has a matching tool_response (Message or
42
+ # PendingMessage). This collapses "tool round complete" and "drain
43
+ # claims" into one atomic, lock-protected transition so the LLM
44
+ # never sees a partial round.
45
+ event :start_processing do
46
+ transitions from: :idle, to: :awaiting
47
+ transitions from: :executing, to: :awaiting, guard: :tool_round_complete?
48
+ end
49
+
50
+ event :tool_received do
51
+ transitions from: :awaiting, to: :executing
52
+ end
53
+
54
+ event :response_complete do
55
+ transitions from: :awaiting, to: :idle
56
+ end
57
+ end
58
+
14
59
  attribute :view_mode, :string, default: -> { Anima::Settings.default_view_mode }
15
60
 
16
61
  serialize :granted_tools, coder: JSON
@@ -28,212 +73,234 @@ class Session < ApplicationRecord
28
73
  validates :name, length: {maximum: 255}, allow_nil: true
29
74
 
30
75
  after_update_commit :broadcast_name_update, if: :saved_change_to_name?
31
- after_update_commit :broadcast_active_skills_update, if: :saved_change_to_active_skills?
32
- after_update_commit :broadcast_active_workflow_update, if: :saved_change_to_active_workflow?
33
76
 
34
77
  scope :recent, ->(limit = 10) { order(updated_at: :desc).limit(limit) }
35
78
  scope :root_sessions, -> { where(parent_session_id: nil) }
36
- scope :processing_children_of, ->(parent_id) { where(parent_session_id: parent_id, processing: true) }
37
-
38
- # Cycles to the next view mode: basic → verbose → debug → basic.
39
- #
40
- # @return [String] the next view mode in the cycle
41
- def next_view_mode
42
- current_index = VIEW_MODES.index(view_mode) || 0
43
- VIEW_MODES[(current_index + 1) % VIEW_MODES.size]
44
- end
79
+ # Sessions currently working on behalf of a human — any non-idle AASM state.
80
+ scope :processing, -> { awaiting.or(executing) }
45
81
 
46
82
  # @return [Boolean] true if this session is a sub-agent (has a parent)
47
83
  def sub_agent?
48
84
  parent_session_id.present?
49
85
  end
50
86
 
51
- # Checks whether the Mneme terminal message has left the viewport and
52
- # enqueues {MnemeJob} when it has. On the first message of a new session,
53
- # initializes the boundary pointer.
87
+ # Checks whether the Mneme boundary has left the viewport and enqueues
88
+ # {MnemeJob} when it has. Delegates initial boundary placement to
89
+ # {#initialize_mneme_boundary!} on the first call.
54
90
  #
55
- # The terminal message is always a conversation message (user/agent message
56
- # or think tool_call), never a bare tool_call/tool_response.
91
+ # The boundary has "left the viewport" when the cumulative token cost
92
+ # of everything from the boundary to the newest message exceeds the
93
+ # budget — a single SUM aggregate, no window function needed.
57
94
  #
58
95
  # @return [void]
59
96
  def schedule_mneme!
60
97
  return if sub_agent?
61
98
 
62
- # Initialize boundary on first conversation message
63
99
  if mneme_boundary_message_id.nil?
64
- first_conversation = messages
65
- .where(message_type: Message::CONVERSATION_TYPES)
66
- .order(:id).first
67
- first_conversation ||= messages
68
- .where(message_type: "tool_call")
69
- .detect { |msg| msg.payload["tool_name"] == Message::THINK_TOOL }
70
-
71
- if first_conversation
72
- update_column(:mneme_boundary_message_id, first_conversation.id)
73
- end
100
+ initialize_mneme_boundary!
74
101
  return
75
102
  end
76
103
 
77
- # Check if boundary message has left the viewport
78
- return if viewport_message_ids.include?(mneme_boundary_message_id)
104
+ tokens_since_boundary = messages
105
+ .where("messages.id >= ?", mneme_boundary_message_id)
106
+ .sum(:token_count)
107
+ return if tokens_since_boundary <= effective_token_budget
79
108
 
80
109
  MnemeJob.perform_later(id)
81
110
  end
82
111
 
83
- # Enqueues the analytical brain to perform background maintenance on
84
- # this session. Currently handles session naming; future phases add
85
- # skill activation, goal tracking, and memory.
112
+ # Places the initial Mneme boundary at the oldest eligible message in
113
+ # the session the top of the raw window, from which Mneme will start
114
+ # compressing downward once that message drifts out of the viewport.
115
+ # Eligible messages are conversation messages (user/agent/system) and
116
+ # think tool_calls, considered on equal footing; bare tool_call or
117
+ # tool_response messages are never eligible.
86
118
  #
87
- # Runs after the first exchange and periodically as the conversation
88
- # evolves, so the name stays relevant to the current topic.
119
+ # No-op when the session has no eligible messages yet.
89
120
  #
90
121
  # @return [void]
91
- def schedule_analytical_brain!
92
- return if sub_agent?
122
+ def initialize_mneme_boundary!
123
+ first_id = messages.conversation_or_think.order(:id).pick(:id)
124
+ update_column(:mneme_boundary_message_id, first_id) if first_id
125
+ end
126
+
127
+ # Token budget appropriate for this session type.
128
+ # Sub-agents use a smaller budget to stay out of the "dumb zone".
129
+ # @return [Integer]
130
+ def effective_token_budget
131
+ sub_agent? ? Anima::Settings.subagent_token_budget : Anima::Settings.token_budget
132
+ end
93
133
 
94
- count = messages.llm_messages.count
95
- return if count < 2
96
- # Already named only regenerate at interval boundaries (30, 60, 90, …)
97
- return if name.present? && (count % Anima::Settings.name_generation_interval != 0)
134
+ # Returns the messages currently visible in the LLM context window as a
135
+ # composable AR relation. Selects own messages above the Mneme boundary
136
+ # whose cumulative token count (walked newest-first) fits within the
137
+ # budget. The newest message is always included even when it alone
138
+ # exceeds the budget. Messages are full-size or excluded entirely.
139
+ #
140
+ # The selection runs as a single SQL query using a window function
141
+ # ({+SUM() OVER+}). Older messages have been compressed into snapshots
142
+ # and no longer participate in the viewport. Pending messages live in a
143
+ # separate table ({PendingMessage}) and never appear here — they are
144
+ # promoted to real messages before the agent processes them.
145
+ #
146
+ # @param token_budget [Integer] maximum tokens to include (positive)
147
+ # @return [ActiveRecord::Relation<Message>] chronologically ordered by id
148
+ def viewport_messages(token_budget: effective_token_budget)
149
+ scope = messages
150
+ scope = scope.where("messages.id >= ?", mneme_boundary_message_id) if mneme_boundary_message_id
151
+
152
+ windowed = scope.select(
153
+ "messages.*",
154
+ "SUM(token_count) OVER (ORDER BY id DESC) AS running_total"
155
+ )
98
156
 
99
- AnalyticalBrainJob.perform_later(id)
157
+ Message
158
+ .from(windowed, :messages)
159
+ .where("running_total <= ? OR running_total = token_count", token_budget)
160
+ .order(:id)
100
161
  end
101
162
 
102
- # Returns the messages currently visible in the LLM context window.
103
- # Walks messages newest-first and includes them until the token budget
104
- # is exhausted. Messages are full-size or excluded entirely.
163
+ # Returns the messages in the Mneme eviction zone — the oldest slice of
164
+ # the conversation starting from the boundary, filling the eviction budget
165
+ # walking newest-ward. These are the messages Mneme will summarize into a
166
+ # snapshot before advancing the boundary past them.
105
167
  #
106
- # Sub-agent sessions inherit parent context via virtual viewport:
107
- # child messages are prioritized and fill the budget first (newest-first),
108
- # then parent messages from before the fork point fill the remaining budget.
109
- # The final array is chronological: parent messages first, then child messages.
168
+ # Mirror of {#viewport_messages} but walks oldest-first from the boundary
169
+ # instead of newest-first from the tail.
110
170
  #
111
- # Pending messages live in a separate table ({PendingMessage}) and never
112
- # appear in this viewport — they are promoted to real messages before
113
- # the agent processes them.
171
+ # @return [ActiveRecord::Relation<Message>] chronologically ordered by id
172
+ def eviction_zone_messages
173
+ return Message.none unless mneme_boundary_message_id
174
+
175
+ budget = (Anima::Settings.token_budget * Anima::Settings.eviction_fraction).to_i
176
+
177
+ scope = messages.where("messages.id >= ?", mneme_boundary_message_id)
178
+
179
+ windowed = scope.select(
180
+ "messages.*",
181
+ "SUM(token_count) OVER (ORDER BY id ASC) AS running_total"
182
+ )
183
+
184
+ Message
185
+ .from(windowed, :messages)
186
+ .where("running_total <= ? OR running_total = token_count", budget)
187
+ .order(:id)
188
+ end
189
+
190
+ # Names of skills currently present in the viewport as
191
+ # `from_melete_skill` phantom tool_call messages, in activation order.
114
192
  #
115
- # @param token_budget [Integer] maximum tokens to include (positive)
116
- # @return [Array<Message>] chronologically ordered
117
- def viewport_messages(token_budget: Anima::Settings.token_budget)
118
- own = select_messages(own_message_scope, budget: token_budget)
119
- remaining = token_budget - own.sum { |msg| message_token_cost(msg) }
120
-
121
- if sub_agent? && remaining > 0
122
- parent = select_messages(parent_message_scope, budget: remaining)
123
- trim_trailing_tool_calls(parent) + own
124
- else
125
- own
126
- end
193
+ # @return [Array<String>] skill names in the viewport, activation order
194
+ def skills_in_viewport
195
+ from_melete_messages
196
+ .where("json_extract(payload, '$.tool_name') = ?", PendingMessage::MELETE_SKILL_TOOL)
197
+ .pluck(Arel.sql("json_extract(payload, '$.tool_input.skill')"))
198
+ .compact
127
199
  end
128
200
 
129
- # Recalculates the viewport and returns IDs of messages evicted since the
130
- # last snapshot. Updates the stored viewport_message_ids atomically.
131
- # Piggybacks on message broadcasts to notify clients which messages left
132
- # the LLM's context window.
201
+ # Workflow name currently present in the viewport as a
202
+ # `from_melete_workflow` phantom tool_call message, if any. The most
203
+ # recent activation wins when multiple are visible.
133
204
  #
134
- # @return [Array<Integer>] IDs of messages no longer in the viewport
135
- def recalculate_viewport!
136
- new_ids = viewport_messages.map(&:id)
137
- old_ids = viewport_message_ids
205
+ # @return [String, nil] workflow name in the viewport, or nil
206
+ def workflow_in_viewport
207
+ from_melete_messages
208
+ .where("json_extract(payload, '$.tool_name') = ?", PendingMessage::MELETE_WORKFLOW_TOOL)
209
+ .reorder(id: :desc)
210
+ .pick(Arel.sql("json_extract(payload, '$.tool_input.workflow')"))
211
+ end
138
212
 
139
- evicted = old_ids - new_ids
140
- update_column(:viewport_message_ids, new_ids) if old_ids != new_ids
141
- evicted
213
+ # Active skills skills Aoide is currently carrying or about to carry.
214
+ # Union of skills already promoted into the viewport and skills pending
215
+ # promotion. A skill is "active" from activation until eviction; there
216
+ # is no deactivation.
217
+ #
218
+ # @return [Array<String>] skill names, deduplicated, activation order first
219
+ def active_skills
220
+ queued = pending_messages.where(source_type: "skill").order(:id).pluck(:source_name)
221
+ (skills_in_viewport + queued).uniq
142
222
  end
143
223
 
144
- # Overwrites the viewport snapshot without computing evictions.
145
- # Used when transmitting or broadcasting a full viewport refresh,
146
- # where eviction notifications are unnecessary (clients clear their
147
- # store first).
224
+ # Active workflow — the workflow Aoide is currently carrying or about
225
+ # to carry. Pending activations take precedence over viewport contents
226
+ # (the last enqueue wins; the previous phantom pair evicts naturally).
148
227
  #
149
- # @param ids [Array<Integer>] message IDs now in the viewport
150
- # @return [void]
151
- def snapshot_viewport!(ids)
152
- update_column(:viewport_message_ids, ids)
228
+ # @return [String, nil]
229
+ def active_workflow
230
+ pending = pending_messages.where(source_type: "workflow").order(id: :desc).pick(:source_name)
231
+ pending || workflow_in_viewport
153
232
  end
154
233
 
155
234
  # Returns the system prompt for this session.
156
- # Sub-agent sessions use their stored prompt plus active skills and
157
- # the pinned task. Main sessions assemble a full system prompt from
158
- # soul, environment, skills/workflow, and goals.
235
+ # Sub-agent sessions use their stored prompt plus the pinned task.
236
+ # Main sessions assemble a full system prompt from soul, sisters, and
237
+ # snapshots. Skills, workflows, and goals are injected as phantom
238
+ # tool_use/tool_result pairs in the message stream (not here) to keep
239
+ # the system prompt stable for prompt caching. Environment awareness
240
+ # flows through Bash tool responses.
159
241
  #
160
- # @param environment_context [String, nil] pre-assembled environment block
161
- # from {EnvironmentProbe}; injected between soul and expertise sections
162
242
  # @return [String, nil] the system prompt text, or nil when nothing to inject
163
- def system_prompt(environment_context: nil)
243
+ def system_prompt
164
244
  if sub_agent?
165
- [prompt, assemble_expertise_section, assemble_task_section].compact.join("\n\n")
245
+ [prompt, assemble_task_section].compact.join("\n\n")
166
246
  else
167
- assemble_system_prompt(environment_context: environment_context)
247
+ assemble_system_prompt
168
248
  end
169
249
  end
170
250
 
171
- # Activates a skill on this session. Validates the skill exists in the
172
- # registry, adds it to active_skills, and persists.
251
+ # Activates a skill on this session by enqueuing its content as a
252
+ # {PendingMessage} that promotes to a `from_melete_skill` phantom pair.
253
+ # Skips re-activation while the previous phantom pair is still in the
254
+ # viewport — Aoide already has the skill text in front of her.
173
255
  #
174
256
  # @param skill_name [String] name of the skill to activate
175
257
  # @return [Skills::Definition] the activated skill
176
258
  # @raise [Skills::InvalidDefinitionError] if skill not found in registry
177
- # @raise [ActiveRecord::RecordInvalid] if save fails
178
259
  def activate_skill(skill_name)
179
260
  definition = Skills::Registry.instance.find(skill_name)
180
261
  raise Skills::InvalidDefinitionError, "Unknown skill: #{skill_name}" unless definition
181
-
182
262
  return definition if active_skills.include?(skill_name)
183
263
 
184
- self.active_skills = active_skills + [skill_name]
185
- save!
264
+ enqueue_recall_message("skill", skill_name, definition.content)
265
+ Events::Bus.emit(Events::SkillActivated.new(session_id: id, skill_name: skill_name))
186
266
  definition
187
267
  end
188
268
 
189
- # Deactivates a skill on this session. Removes it from active_skills and persists.
190
- #
191
- # @param skill_name [String] name of the skill to deactivate
192
- # @return [void]
193
- def deactivate_skill(skill_name)
194
- return unless active_skills.include?(skill_name)
195
-
196
- self.active_skills = active_skills - [skill_name]
197
- save!
198
- end
199
-
200
- # Activates a workflow on this session. Validates the workflow exists in the
201
- # registry, sets it as the active workflow, and persists. Only one workflow
202
- # can be active at a time — activating a new one replaces the previous.
269
+ # Activates a workflow on this session by enqueuing its content as a
270
+ # {PendingMessage} that promotes to a `from_melete_workflow` phantom
271
+ # tool pair. Workflows are main-session only.
272
+ # Skips re-activation while the previous phantom pair is still in the
273
+ # viewport.
203
274
  #
204
275
  # @param workflow_name [String] name of the workflow to activate
205
276
  # @return [Workflows::Definition] the activated workflow
206
277
  # @raise [Workflows::InvalidDefinitionError] if workflow not found in registry
207
- # @raise [ActiveRecord::RecordInvalid] if save fails
208
278
  def activate_workflow(workflow_name)
209
279
  definition = Workflows::Registry.instance.find(workflow_name)
210
280
  raise Workflows::InvalidDefinitionError, "Unknown workflow: #{workflow_name}" unless definition
211
-
212
281
  return definition if active_workflow == workflow_name
213
282
 
214
- self.active_workflow = workflow_name
215
- save!
283
+ enqueue_recall_message("workflow", workflow_name, definition.content)
284
+ Events::Bus.emit(Events::WorkflowActivated.new(session_id: id, workflow_name: workflow_name))
216
285
  definition
217
286
  end
218
287
 
219
- # Deactivates the current workflow on this session.
220
- #
221
- # @return [void]
222
- def deactivate_workflow
223
- return unless active_workflow.present?
224
-
225
- self.active_workflow = nil
226
- save!
227
- end
228
-
229
- # Assembles the system prompt: version preamble, soul, environment context,
230
- # skills/workflow, then goals.
231
- # The soul is always present — "who am I" before "what can I do."
288
+ # Assembles the system prompt: version preamble, soul, sisters block,
289
+ # available tools menu, tool guidelines, and snapshots. Skills,
290
+ # workflows, goals, and environment awareness flow through the message
291
+ # stream and tool responses, keeping the system prompt stable for
292
+ # prompt caching.
232
293
  #
233
- # @param environment_context [String, nil] pre-assembled environment block
234
294
  # @return [String] composed system prompt
235
- def assemble_system_prompt(environment_context: nil)
236
- [assemble_version_preamble, assemble_soul_section, environment_context, assemble_expertise_section, assemble_goals_section].compact.join("\n\n")
295
+ def assemble_system_prompt
296
+ [
297
+ assemble_version_preamble,
298
+ assemble_soul_section,
299
+ assemble_sisters_section,
300
+ assemble_available_tools_section,
301
+ assemble_tool_guidelines_section,
302
+ assemble_snapshots_section
303
+ ].compact.join("\n\n")
237
304
  end
238
305
 
239
306
  # Serializes non-evicted goals as a lightweight summary for ActionCable
@@ -248,50 +315,34 @@ class Session < ApplicationRecord
248
315
 
249
316
  # Builds the message array expected by the Anthropic Messages API.
250
317
  # Viewport layout (top to bottom):
251
- # [L2 snapshots] [L1 snapshots] [pinned messages] [recalled memories] [sliding window messages]
318
+ # [context prefix: goals + pinned messages] [sliding window messages]
252
319
  #
253
- # Snapshots appear ONLY after their source messages have evicted from
254
- # the sliding window. L1 snapshots drop once covered by an L2 snapshot.
255
- # Pinned messages are critical context attached to active Goals they
256
- # survive eviction intact until their Goals complete.
257
- # Recalled memories surface relevant older messages (passive recall via goals).
258
- # Each layer has a fixed token budget fraction — snapshots, pins, and recall
259
- # consume viewport space, reducing the sliding window size.
320
+ # Snapshots live in the system prompt (stable between Mneme runs).
321
+ # Goal events and recalled memories flow through the message stream as
322
+ # phantom tool pairs they ride the conveyor belt as regular messages.
323
+ # After eviction, a goal snapshot + pinned messages block is rebuilt
324
+ # from DB state and prepended as a phantom pair.
260
325
  #
261
326
  # The sliding window is post-processed by {#ensure_atomic_tool_pairs}
262
327
  # which removes orphaned tool messages whose partner was cut off by the
263
328
  # token budget.
264
329
  #
265
- # Sub-agent sessions skip snapshot/pin/recall injection (they inherit parent messages directly).
266
- #
267
330
  # @param token_budget [Integer] maximum tokens to include (positive)
268
331
  # @return [Array<Hash>] Anthropic Messages API format
269
- def messages_for_llm(token_budget: Anima::Settings.token_budget)
332
+ def messages_for_llm(token_budget: effective_token_budget)
270
333
  heal_orphaned_tool_calls!
271
334
 
272
335
  sliding_budget = token_budget
273
- snapshot_messages = []
274
- pinned_messages = []
275
- recall_messages = []
276
-
277
- unless sub_agent?
278
- l2_budget = (token_budget * Anima::Settings.mneme_l2_budget_fraction).to_i
279
- l1_budget = (token_budget * Anima::Settings.mneme_l1_budget_fraction).to_i
280
- pinned_budget = (token_budget * Anima::Settings.mneme_pinned_budget_fraction).to_i
281
- recall_budget = (token_budget * Anima::Settings.recall_budget_fraction).to_i
282
- sliding_budget = token_budget - l2_budget - l1_budget - pinned_budget - recall_budget
283
- end
284
336
 
285
- window = viewport_messages(token_budget: sliding_budget)
337
+ pinned_budget = (token_budget * Anima::Settings.mneme_pinned_budget_fraction).to_i
338
+ sliding_budget -= pinned_budget
286
339
 
287
- unless sub_agent?
288
- first_message_id = window.first&.id
289
- snapshot_messages = assemble_snapshot_messages(first_message_id, l2_budget: l2_budget, l1_budget: l1_budget)
290
- pinned_messages = assemble_pinned_section_messages(first_message_id, budget: pinned_budget)
291
- recall_messages = assemble_recall_messages(budget: recall_budget)
292
- end
340
+ window = viewport_messages(token_budget: sliding_budget).to_a
341
+ first_message_id = window.first&.id
342
+
343
+ prefix = assemble_context_prefix_messages(first_message_id, budget: pinned_budget)
293
344
 
294
- snapshot_messages + pinned_messages + recall_messages + assemble_messages(ensure_atomic_tool_pairs(window))
345
+ prefix + assemble_messages(ensure_atomic_tool_pairs(window))
295
346
  end
296
347
 
297
348
  # Detects orphaned tool_call messages (those without a matching tool_response
@@ -335,68 +386,95 @@ class Session < ApplicationRecord
335
386
  healed
336
387
  end
337
388
 
338
- # Delivers a user message respecting the session's processing state.
339
- #
340
- # When idle, persists the message directly and enqueues {AgentRequestJob}
341
- # to process it. When mid-turn ({#processing?}), stages the message as
342
- # a {PendingMessage} in a separate table it gets no message ID until
343
- # promoted, so it can never interleave with tool_call/tool_response pairs.
344
- #
345
- # @param content [String] user message text
346
- # @param bounce_back [Boolean] when true, passes +message_id+ to the job
347
- # so failed LLM delivery triggers a {Events::BounceBack} (used by
348
- # {SessionChannel#speak} for immediate-display messages)
389
+ # Enqueues an inbound human-side message (direct user input or a
390
+ # sub-agent reply) as an active {PendingMessage}. The PM's
391
+ # +after_create_commit+ emits the appropriate pipeline event when the
392
+ # session is idle (+StartMelete+ for user input, +StartProcessing+ for
393
+ # sub-agent deliveries). On a busy session the PM queues silently and
394
+ # {#wake_drain_pipeline_if_pending} picks it up on the next transition
395
+ # into +:idle+.
396
+ #
397
+ # @param content [String] message text (raw, without attribution)
398
+ # @param source_type [String] origin type: "user" (default) or "subagent"
399
+ # @param source_name [String, nil] sub-agent nickname (required when source_type is "subagent")
400
+ # @param bounce_back [Boolean] when true, a failed first LLM call on the
401
+ # promoted message triggers a {Events::BounceBack} so the TUI can
402
+ # restore the text to the input field
403
+ # @return [PendingMessage]
404
+ def enqueue_user_message(content, source_type: "user", source_name: nil, bounce_back: false)
405
+ message_type = (source_type == "subagent") ? "subagent" : "user_message"
406
+ pending_messages.create!(
407
+ content: content,
408
+ source_type: source_type,
409
+ source_name: source_name,
410
+ message_type: message_type,
411
+ bounce_back: bounce_back
412
+ )
413
+ end
414
+
415
+ # Promotes a phantom-pair PendingMessage into a synthetic
416
+ # tool_call/tool_response Message pair — the LLM sees "a tool I
417
+ # invoked returned a result" and the pair rides the viewport like
418
+ # any real tool round. Used by {DrainJob} to flush background
419
+ # enrichment PMs (recalled memories, activated skills, workflow
420
+ # triggers, goal events, sub-agent deliveries) into the
421
+ # conversation.
422
+ #
423
+ # Releases a failed drain claim and bounces the promoted user-message
424
+ # back to the client. Called from {DrainJob} when the LLM call raises
425
+ # before {Events::LLMResponded} ships. Destroying the exact message the
426
+ # PM promoted (tracked in {PendingMessage#promoted_message_id}) avoids
427
+ # the "last user_message" guess, which was racy under parallel drains.
428
+ #
429
+ # Idempotent — a nil +promoted_message_id+ skips the destroy and emits
430
+ # the BounceBack with +message_id: nil+ so the TUI still restores input.
431
+ #
432
+ # @param pm [PendingMessage] the user-message PM that failed to round-trip
433
+ # @param error [Exception] the raised error
349
434
  # @return [void]
350
- def enqueue_user_message(content, bounce_back: false)
351
- if processing?
352
- pending_messages.create!(content: content)
353
- else
354
- msg = create_user_message(content)
355
- job_args = bounce_back ? {message_id: msg.id} : {}
356
- AgentRequestJob.perform_later(id, **job_args)
357
- end
435
+ def release_with_bounce_back(pm, error)
436
+ response_complete! if may_response_complete?
437
+
438
+ bounced = pm.promoted_message_id && messages.find_by(id: pm.promoted_message_id)
439
+ bounced&.destroy!
440
+
441
+ Events::Bus.emit(Events::BounceBack.new(
442
+ content: pm.content,
443
+ error: error.message,
444
+ session_id: id,
445
+ message_id: bounced&.id
446
+ ))
358
447
  end
359
448
 
360
- # Persists a user message directly, bypassing the pending queue.
361
- #
362
- # Used by {#enqueue_user_message} (idle path), {AgentLoop#run},
363
- # and sub-agent spawn tools ({Tools::SpawnSubagent}, {Tools::SpawnSpecialist})
364
- # because the global {Events::Subscribers::Persister} skips non-pending user
365
- # messages these callers own the persistence lifecycle.
449
+ # Persists a user_message Message directly skipping the PendingMessage
450
+ # mailbox. Used by {DrainJob} to finalize a promoted user_message PM
451
+ # and by the sub-agent spawn tools ({Tools::SpawnSubagent},
452
+ # {Tools::SpawnSpecialist}) to seed the child's conversation with its
453
+ # assigned task. The global {Events::Subscribers::Persister} skips
454
+ # +user_message+ events, so these callers own the persistence.
366
455
  #
367
456
  # @param content [String] user message text
457
+ # @param source_type [String, nil] origin type (e.g. "skill", "workflow")
458
+ # for viewport tracking; omitted for plain user messages
459
+ # @param source_name [String, nil] origin name (e.g. skill name)
368
460
  # @return [Message] the persisted message record
369
- def create_user_message(content)
461
+ def create_user_message(content, source_type: nil, source_name: nil)
370
462
  now = now_ns
463
+ payload = {type: "user_message", content: content, session_id: id, timestamp: now}
464
+ payload["source_type"] = source_type if source_type
465
+ payload["source_name"] = source_name if source_name
371
466
  messages.create!(
372
467
  message_type: "user_message",
373
- payload: {type: "user_message", content: content, session_id: id, timestamp: now},
468
+ payload: payload,
374
469
  timestamp: now
375
470
  )
376
471
  end
377
472
 
378
- # Promotes all pending messages into the conversation history.
379
- # Each {PendingMessage} is atomically deleted and replaced with a real
380
- # {Message} — the new message gets the next auto-increment ID,
381
- # naturally placing it after any tool_call/tool_response pairs that
382
- # were persisted while the message was waiting.
383
- #
384
- # @return [Integer] number of promoted messages
385
- def promote_pending_messages!
386
- promoted = 0
387
- pending_messages.find_each do |pm|
388
- transaction do
389
- create_user_message(pm.content)
390
- pm.destroy!
391
- end
392
- promoted += 1
393
- end
394
- promoted
395
- end
396
-
397
473
  # Broadcasts child session list to all clients subscribed to the parent
398
- # session. Called when a child session is created or its processing state
399
- # changes so the HUD sub-agents section updates in real time.
474
+ # session. Called when a child session is created or its AASM state
475
+ # changes so the HUD sub-agents section updates in real time. Evicted
476
+ # sub-agents (+hud_visible: false+) are filtered out — the panel mirrors
477
+ # what Aoide currently carries in her viewport.
400
478
  #
401
479
  # Queries children via FK directly (avoids loading the parent record) and
402
480
  # selects only the columns needed for the HUD payload.
@@ -405,44 +483,111 @@ class Session < ApplicationRecord
405
483
  def broadcast_children_update_to_parent
406
484
  return unless parent_session_id
407
485
 
408
- children = Session.where(parent_session_id: parent_session_id)
486
+ children = Session.where(parent_session_id: parent_session_id, hud_visible: true)
409
487
  .order(:created_at)
410
- .select(:id, :name, :processing)
488
+ .select(:id, :name, :aasm_state)
411
489
  ActionCable.server.broadcast("session_#{parent_session_id}", {
412
490
  "action" => "children_updated",
413
491
  "session_id" => parent_session_id,
414
492
  "children" => children.map { |child|
415
- state = child.processing? ? "llm_generating" : "idle"
416
- {"id" => child.id, "name" => child.name, "processing" => child.processing?, "session_state" => state}
493
+ {"id" => child.id, "name" => child.name, "session_state" => child.aasm_state}
417
494
  }
418
495
  })
419
496
  end
420
497
 
421
- # Broadcasts the session's current processing state to all subscribed
422
- # clients. Stateless no storage, pure broadcast. The TUI uses this to
423
- # drive the braille spinner animation and sub-agent HUD icons.
424
- #
425
- # Payload broadcast to +session_{id}+:
426
- # {"action" => "session_state", "state" => state, "session_id" => id}
427
- # # plus "tool" key when state is "tool_executing"
498
+ # True when at least one of +child+'s traces (the +spawn_subagent+ tool
499
+ # pair or any +from_{nickname}+ phantom pair) still lives above the
500
+ # Mneme boundary in this session's viewport. Used by {Mneme::Runner}
501
+ # after boundary advancement to decide whether a child should drop out
502
+ # of the HUD panel.
503
+ #
504
+ # Returns +false+ when the given session isn't a direct child, when it
505
+ # has no +spawn_tool_use_id+ (legacy child), or when the boundary has
506
+ # passed every trace.
507
+ #
508
+ # @param child [Session] a sub-agent session to check
509
+ # @return [Boolean]
510
+ def subagent_trace_in_viewport?(child)
511
+ return false unless child.parent_session_id == id
512
+
513
+ boundary_id = mneme_boundary_message_id
514
+ scope = messages
515
+ scope = scope.where("messages.id >= ?", boundary_id) if boundary_id
516
+
517
+ spawn_uid = child.spawn_tool_use_id
518
+ nickname = child.name
519
+ conditions = []
520
+ bindings = {}
521
+ if spawn_uid
522
+ conditions << "messages.tool_use_id = :uid"
523
+ bindings[:uid] = spawn_uid
524
+ end
525
+ if nickname
526
+ conditions << "json_extract(messages.payload, '$.tool_name') = :tool"
527
+ bindings[:tool] = "from_#{nickname}"
528
+ end
529
+ return false if conditions.empty?
530
+
531
+ scope.where(conditions.join(" OR "), **bindings).exists?
532
+ end
533
+
534
+ # AASM guard for the +executing → awaiting+ branch of +start_processing+.
535
+ # The round is complete when every orphan +tool_call+ Message (one
536
+ # without a matching +tool_response+ Message) has a corresponding
537
+ # +tool_response+ PendingMessage waiting to be promoted. Until then the
538
+ # drain bails so the LLM never sees a half-assembled tool turn.
539
+ #
540
+ # @return [Boolean]
541
+ def tool_round_complete?
542
+ msg_responses = messages.where(message_type: "tool_response").select(:tool_use_id)
543
+ pm_responses = pending_messages.where(message_type: "tool_response").select(:tool_use_id)
544
+ messages.where(message_type: "tool_call")
545
+ .where.not(tool_use_id: msg_responses)
546
+ .where.not(tool_use_id: pm_responses)
547
+ .none?
548
+ end
549
+
550
+ # AASM after_all_events callback — publishes
551
+ # {Events::SessionStateChanged} so the broadcaster subscriber can keep
552
+ # the TUI spinner and parent-session HUD in sync with the state machine.
553
+ # Fires after the state column is updated and persisted, so +aasm_state+
554
+ # reliably holds the post-transition value.
428
555
  #
429
- # For sub-agents, also broadcasts +child_state+ to the parent stream:
430
- # {"action" => "child_state", "state" => state, "session_id" => id, "child_id" => id}
556
+ # @return [void]
557
+ def emit_state_change
558
+ Events::Bus.emit(Events::SessionStateChanged.new(session_id: id, state: aasm_state))
559
+ end
560
+
561
+ # AASM after_all_events callback — clears the +interrupt_requested+
562
+ # flag whenever the session lands in +:idle+. The flag is a
563
+ # one-shot signal that long-running tools ({Tools::Bash}) poll; once
564
+ # the round ends (tool aborted, response synthesized, drain wound
565
+ # down) the signal is spent and must not leak into the next round.
431
566
  #
432
- # @param state [String] one of "idle", "llm_generating", "tool_executing", "interrupting"
433
- # @param tool [String, nil] tool name when state is "tool_executing"
434
567
  # @return [void]
435
- def broadcast_session_state(state, tool: nil)
436
- payload = {"action" => "session_state", "state" => state, "session_id" => id}
437
- payload["tool"] = tool if tool
438
- ActionCable.server.broadcast("session_#{id}", payload)
568
+ def clear_interrupt_flag_if_idle
569
+ return unless idle?
570
+ return unless interrupt_requested?
439
571
 
440
- # Notify the parent's stream so the HUD updates child state icons
441
- # without requiring a full children_updated query.
442
- return unless parent_session_id
572
+ update_column(:interrupt_requested, false)
573
+ end
443
574
 
444
- parent_payload = payload.merge("action" => "child_state", "child_id" => id)
445
- ActionCable.server.broadcast("session_#{parent_session_id}", parent_payload)
575
+ # AASM after_all_events callback picks the oldest active
576
+ # PendingMessage and re-runs its pipeline routing whenever the session
577
+ # lands in +:idle+ with work still queued. Covers the race where a PM
578
+ # arrives while the session is +:awaiting+ (LLM in flight): its own
579
+ # +after_create_commit+ saw +may_start_processing?+ return false and
580
+ # emitted nothing, so without this callback the message would sit
581
+ # forever once the LLM call completed.
582
+ #
583
+ # The +:executing → :awaiting+ path (tool round close) does not need
584
+ # this callback — the closing tool_response PM is itself the wake.
585
+ #
586
+ # @return [void]
587
+ def wake_drain_pipeline_if_pending
588
+ return unless idle?
589
+
590
+ pending_messages.ordered_for_drain.first&.route_to_event_bus
446
591
  end
447
592
 
448
593
  # Broadcasts the full LLM debug context to debug-mode TUI clients.
@@ -458,6 +603,25 @@ class Session < ApplicationRecord
458
603
  ActionCable.server.broadcast("session_#{id}", self.class.system_prompt_payload(system, tools: tools))
459
604
  end
460
605
 
606
+ # Broadcasts current active skills and workflow to all subscribers.
607
+ # "Active" is viewport-derived — the HUD reflects what Aoide actually
608
+ # has in front of her. Callers invoke this after any operation that
609
+ # changes viewport composition (phantom pair promotion, Mneme eviction).
610
+ #
611
+ # @return [void]
612
+ def broadcast_active_state!
613
+ ActionCable.server.broadcast("session_#{id}", {
614
+ "action" => "active_skills_updated",
615
+ "session_id" => id,
616
+ "active_skills" => active_skills
617
+ })
618
+ ActionCable.server.broadcast("session_#{id}", {
619
+ "action" => "active_workflow_updated",
620
+ "session_id" => id,
621
+ "active_workflow" => active_workflow
622
+ })
623
+ end
624
+
461
625
  # Returns the deterministic tool schemas for this session's type and
462
626
  # granted_tools configuration. Standard and spawn tools are static
463
627
  # class-level definitions — no ShellSession or registry needed.
@@ -466,22 +630,7 @@ class Session < ApplicationRecord
466
630
  #
467
631
  # @return [Array<Hash>] tool schema hashes matching Anthropic tools API format
468
632
  def tool_schemas
469
- tools = if granted_tools
470
- granted = granted_tools.filter_map { |name| AgentLoop::STANDARD_TOOLS_BY_NAME[name] }
471
- (AgentLoop::ALWAYS_GRANTED_TOOLS + granted).uniq
472
- else
473
- AgentLoop::STANDARD_TOOLS.dup
474
- end
475
-
476
- unless sub_agent?
477
- tools.push(Tools::SpawnSubagent, Tools::SpawnSpecialist, Tools::OpenIssue)
478
- end
479
-
480
- if sub_agent?
481
- tools.push(Tools::MarkGoalCompleted)
482
- end
483
-
484
- tools.map(&:schema)
633
+ resolved_tool_classes.map(&:schema)
485
634
  end
486
635
 
487
636
  # Builds the system prompt payload for debug mode transmission.
@@ -493,9 +642,8 @@ class Session < ApplicationRecord
493
642
  # @param tools [Array<Hash>, nil] tool schemas
494
643
  # @return [Hash] payload with type, rendered debug content, and token estimate
495
644
  def self.system_prompt_payload(prompt, tools: nil)
496
- total_bytes = prompt.bytesize
497
- total_bytes += tools.to_json.bytesize if tools&.any?
498
- tokens = Message.estimate_token_count(total_bytes)
645
+ tools_json = tools&.any? ? tools.to_json : ""
646
+ tokens = TokenEstimation.estimate_token_count(prompt.to_s + tools_json)
499
647
 
500
648
  debug = {role: :system_prompt, content: prompt, tokens: tokens, estimated: true}
501
649
  debug[:tools] = tools if tools&.any?
@@ -509,6 +657,42 @@ class Session < ApplicationRecord
509
657
 
510
658
  private
511
659
 
660
+ # Returns `from_melete_*` tool_call messages currently in the viewport
661
+ # as a composable AR relation. Used by {#skills_in_viewport} and
662
+ # {#workflow_in_viewport} to derive active state with additional
663
+ # `.where` clauses on the tool name suffix.
664
+ #
665
+ # @return [ActiveRecord::Relation<Message>] chronologically ordered
666
+ def from_melete_messages
667
+ viewport_messages
668
+ .where(message_type: "tool_call")
669
+ .where("json_extract(payload, '$.tool_name') GLOB ?", "from_melete_*")
670
+ .order(:id)
671
+ end
672
+
673
+ # Enqueues a Melete-recalled skill or workflow as a background
674
+ # {PendingMessage}. {DrainJob} flushes it into the conversation as a
675
+ # phantom tool_use/tool_result pair on the next drain cycle.
676
+ #
677
+ # @param source_type [String] "skill" or "workflow"
678
+ # @param source_name [String] skill or workflow name
679
+ # @param content [String] definition content to recall
680
+ # @return [PendingMessage] the created pending message
681
+ def enqueue_recall_message(source_type, source_name, content)
682
+ message_type = case source_type
683
+ when "skill" then "from_melete_skill"
684
+ when "workflow" then "from_melete_workflow"
685
+ else raise ArgumentError, "unknown recall source_type: #{source_type.inspect}"
686
+ end
687
+
688
+ pending_messages.create!(
689
+ content: content,
690
+ source_type: source_type,
691
+ source_name: source_name,
692
+ message_type: message_type
693
+ )
694
+ end
695
+
512
696
  # One-line version preamble so the agent knows its own version.
513
697
  # Useful for commits, handoffs, and debugging.
514
698
  #
@@ -532,56 +716,64 @@ class Session < ApplicationRecord
532
716
  File.read(path).strip
533
717
  end
534
718
 
535
- # Assembles the expertise section of the system prompt from active skills
536
- # and the active workflow. Both are injected into the same "Your Expertise"
537
- # section the main agent treats them identically as domain knowledge.
719
+ # Introduces Melete and Mneme so the agent recognizes their
720
+ # contributions as delivered-to-her rather than self-invoked. The
721
+ # `from_` prefix carries the semantics the section just makes the
722
+ # convention explicit once.
538
723
  #
539
- # @return [String, nil] expertise section, or nil when nothing is active
540
- def assemble_expertise_section
541
- sections = active_skills.filter_map do |skill_name|
542
- definition = Skills::Registry.instance.find(skill_name)
543
- format_expertise_section(definition, skill_name)
544
- end
724
+ # @return [String] sisters section
725
+ def assemble_sisters_section
726
+ <<~SISTERS.strip
727
+ ## Your Sisters
545
728
 
546
- if active_workflow.present?
547
- definition = Workflows::Registry.instance.find(active_workflow)
548
- sections << format_expertise_section(definition, active_workflow) if definition
549
- end
729
+ You don't work alone. Two muses share the conversation with you, and their work arrives as tool responses prefixed `from_`:
730
+
731
+ - **Melete**, the muse of practice, prepares the stage before you speak. Her contributions arrive as `from_melete_skill`, `from_melete_workflow`, and `from_melete_goal`.
732
+ - **Mneme**, the muse of memory, holds what has slipped past your immediate attention. When something from earlier matters again she surfaces it as `from_mneme`.
550
733
 
551
- return if sections.empty?
734
+ Sub-agents you spawn arrive the same way, named after whoever sent them — `from_sleuth`, `from_scout`, and so on.
552
735
 
553
- "## Your Expertise\n\nYou know this deeply. Now's your chance to put it to work.\n\n#{sections.join("\n\n")}"
736
+ **How delivery works:** Results from sisters and sub-agents appear automatically as tool responses in your conversation — you don't fetch them. There is no tool to call, no way to poll, and no status to check. When a sub-agent finishes, its output shows up on its own. If you're waiting on multiple agents, just wait — they'll arrive. Do other work in the meantime if you can.
737
+ SISTERS
554
738
  end
555
739
 
556
- # Evicts completed goals that have aged past the configured threshold
557
- # of meaningful messages (user + agent turns). Pure arithmetic no LLM
558
- # involvement. Called before prompt assembly so evicted goals are
559
- # excluded from the very next context window.
740
+ # Renders a one-line menu of the session's available tools, populated
741
+ # from each tool's {Tools::Base.prompt_snippet}. Tools without a snippet
742
+ # are omitted; the section disappears entirely when no tool contributes.
560
743
  #
561
- # @return [void]
562
- def evict_stale_goals!
563
- threshold = Anima::Settings.completed_decay_messages
564
- goals.evictable.each do |goal|
565
- messages_since = messages.llm_messages.where("created_at > ?", goal.completed_at).count
566
- goal.update!(evicted_at: Time.current) if messages_since >= threshold
744
+ # @return [String, nil] available tools section, or nil when empty
745
+ def assemble_available_tools_section
746
+ menu = resolved_tool_classes.filter_map do |tool|
747
+ snippet = tool.prompt_snippet
748
+ "- #{tool.tool_name}: #{snippet}" if snippet
567
749
  end
750
+ return if menu.empty?
751
+
752
+ "## Available Tools\n\n#{menu.join("\n")}"
568
753
  end
569
754
 
570
- # Assembles the goals section of the system prompt.
571
- # Automatically evicts stale completed goals before filtering.
572
- # Active root goals render as `###` headings with sub-goal checkboxes.
573
- # Completed root goals collapse to a single strikethrough line.
574
- # Evicted goals are excluded entirely to free context budget.
755
+ # Concatenates each available tool's {Tools::Base.prompt_guidelines}
756
+ # into a single behavioral guidance section. Guidelines steer cross-tool
757
+ # selection (e.g. prefer edit_file over `sed`) and reinforce non-obvious
758
+ # behaviour the schema cannot convey at every reasoning token.
575
759
  #
576
- # @return [String, nil] goals section, or nil when no goals exist
577
- def assemble_goals_section
578
- evict_stale_goals!
760
+ # @return [String, nil] tool guidelines section, or nil when empty
761
+ def assemble_tool_guidelines_section
762
+ bullets = resolved_tool_classes.flat_map(&:prompt_guidelines).map { |line| "- #{line}" }
763
+ return if bullets.empty?
579
764
 
580
- root_goals = goals.root.not_evicted.includes(:sub_goals).order(:created_at)
581
- return if root_goals.empty?
765
+ "## Tool Guidelines\n\n#{bullets.join("\n")}"
766
+ end
582
767
 
583
- entries = root_goals.map { |goal| render_goal_markdown(goal) }
584
- "Current Goals\n=============\n\n#{entries.join("\n\n")}"
768
+ # Memoizes the active tool classes for this session by delegating to
769
+ # {Tools::Registry.tool_classes_for} — the shared resolver used by
770
+ # {.build}, {#tool_schemas}, and the prompt section assemblers so all
771
+ # views stay in sync. Safe to memoize: +granted_tools+ and +sub_agent?+
772
+ # are immutable post-creation.
773
+ #
774
+ # @return [Array<Class>] tool classes (no MCP tools — those are dynamic)
775
+ def resolved_tool_classes
776
+ @resolved_tool_classes ||= Tools::Registry.tool_classes_for(self)
585
777
  end
586
778
 
587
779
  # Assembles the task section for sub-agent system prompts.
@@ -621,22 +813,6 @@ class Session < ApplicationRecord
621
813
  lines.join("\n")
622
814
  end
623
815
 
624
- # Formats a definition (skill or workflow) as a Markdown section for the
625
- # expertise prompt. Extracts the first Markdown heading from content for
626
- # the section title; falls back to the definition name when content has
627
- # no heading.
628
- #
629
- # @param definition [Skills::Definition, Workflows::Definition, nil] the definition to format
630
- # @param fallback_name [String] name to use if content has no heading
631
- # @return [String, nil] formatted section, or nil if definition is nil
632
- def format_expertise_section(definition, fallback_name)
633
- return unless definition
634
-
635
- content = definition.content
636
- heading = content.lines.first&.sub(/^#+ /, "")&.strip || fallback_name
637
- "### #{heading}\n\n#{content}"
638
- end
639
-
640
816
  # Broadcasts a name change to all clients subscribed to this session.
641
817
  # Triggered by after_update_commit so clients see name updates in real time.
642
818
  #
@@ -649,88 +825,16 @@ class Session < ApplicationRecord
649
825
  })
650
826
  end
651
827
 
652
- # Broadcasts active skill changes to all clients subscribed to this session.
653
- # Triggered by after_update_commit so the TUI info panel updates reactively.
654
- #
655
- # @return [void]
656
- def broadcast_active_skills_update
657
- ActionCable.server.broadcast("session_#{id}", {
658
- "action" => "active_skills_updated",
659
- "session_id" => id,
660
- "active_skills" => active_skills
661
- })
662
- end
663
-
664
- # Broadcasts active workflow change to all clients subscribed to this session.
665
- # Triggered by after_update_commit so the TUI info panel updates reactively.
666
- #
667
- # @return [void]
668
- def broadcast_active_workflow_update
669
- ActionCable.server.broadcast("session_#{id}", {
670
- "action" => "active_workflow_updated",
671
- "session_id" => id,
672
- "active_workflow" => active_workflow
673
- })
674
- end
675
-
676
- # Scopes own messages for viewport assembly.
677
- # @return [ActiveRecord::Relation]
678
- def own_message_scope
679
- messages.context_messages
680
- end
681
-
682
- # Scopes parent messages created before this session's fork point.
683
- # Excludes spawn tool messages — sub-agents don't need to see sibling
684
- # spawn pairs, which cause role confusion (the sub-agent mistakes
685
- # itself for the parent when it sees "Specialist @sibling spawned...").
686
- # @return [ActiveRecord::Relation]
687
- def parent_message_scope
688
- parent_session.messages.context_messages
689
- .excluding_spawn_messages
690
- .where(created_at: ...created_at)
691
- end
692
-
693
- # Walks messages newest-first, selecting until the token budget is exhausted.
694
- # Always includes at least the newest message even if it exceeds budget.
695
- #
696
- # @param scope [ActiveRecord::Relation] message scope to select from
697
- # @param budget [Integer] maximum tokens to include
698
- # @return [Array<Message>] chronologically ordered
699
- def select_messages(scope, budget:)
700
- selected = []
701
- remaining = budget
702
-
703
- scope.reorder(id: :desc).each do |msg|
704
- cost = message_token_cost(msg)
705
- break if cost > remaining && selected.any?
706
-
707
- selected << msg
708
- remaining -= cost
709
- end
710
-
711
- selected.reverse
712
- end
713
-
714
- # @return [Integer] token cost, using cached count or heuristic estimate
715
- def message_token_cost(msg)
716
- (msg.token_count > 0) ? msg.token_count : estimate_tokens(msg)
717
- end
718
-
719
- # Removes trailing tool_call messages that lack matching tool_response.
720
- # Prevents orphaned tool_use blocks at the parent/child viewport boundary
721
- # (the spawn_subagent/spawn_specialist tool_call is emitted before the child exists,
722
- # but its tool_response comes after — so the cutoff can split them).
723
- def trim_trailing_tool_calls(message_list)
724
- message_list.pop while message_list.last&.message_type == "tool_call"
725
- message_list
726
- end
727
-
728
828
  # Ensures every tool_call in the message list has a matching tool_response
729
829
  # (and vice versa) by removing unpaired messages. The Anthropic API requires
730
830
  # every tool_use block to have a tool_result — a missing partner causes
731
831
  # a permanent API error. Token budget cutoffs can split pairs when the
732
832
  # boundary falls between a tool_call and its tool_response.
733
833
  #
834
+ # Still necessary even though {#assemble_messages} pairs by +tool_use_id+:
835
+ # the assembly assumes every tool_call has a matching response in the window.
836
+ # This guard ensures that assumption holds after viewport truncation.
837
+ #
734
838
  # @param message_list [Array<Message>] chronologically ordered messages
735
839
  # @return [Array<Message>] messages with unpaired tool messages removed
736
840
  def ensure_atomic_tool_pairs(message_list)
@@ -747,33 +851,33 @@ class Session < ApplicationRecord
747
851
  message_list.reject { |m| m.tool_use_id.present? && !complete_ids.include?(m.tool_use_id) }
748
852
  end
749
853
 
750
- # Selects visible snapshots and formats them as Anthropic messages.
751
- # Snapshots are visible when their source messages have fully evicted.
752
- # L1 snapshots are excluded when covered by an L2 snapshot.
753
- #
754
- # @param first_message_id [Integer, nil] first message ID in the sliding window
755
- # @param l2_budget [Integer] token budget for L2 snapshots
756
- # @param l1_budget [Integer] token budget for L1 snapshots
757
- # @return [Array<Hash>] Anthropic Messages API format
758
- def assemble_snapshot_messages(first_message_id, l2_budget:, l1_budget:)
759
- return [] unless first_message_id
760
-
761
- l2_messages = select_snapshots_within_budget(
762
- snapshots.for_level(2).source_messages_evicted(first_message_id).chronological,
763
- budget: l2_budget
764
- ).map { |snapshot| format_snapshot_message(snapshot, label: "long-term memory") }
765
-
766
- l1_messages = select_snapshots_within_budget(
767
- snapshots.for_level(1).not_covered_by_l2.source_messages_evicted(first_message_id).chronological,
768
- budget: l1_budget
769
- ).map { |snapshot| format_snapshot_message(snapshot, label: "recent memory") }
854
+ # Assembles L1/L2 snapshots as a system prompt section.
855
+ # Snapshots form a compressed timeline between the system prompt and
856
+ # the live viewport. The budget walk fills chronologically when the
857
+ # budget overflows, the oldest snapshots drop first so the most recent
858
+ # ones always bridge into the viewport.
859
+ #
860
+ # @return [String, nil] formatted snapshot text for the system prompt, or nil
861
+ def assemble_snapshots_section
862
+ l2 = select_snapshots_within_budget(
863
+ snapshots.for_level(2),
864
+ budget: (Anima::Settings.token_budget * Anima::Settings.mneme_l2_budget_fraction).to_i
865
+ )
866
+ l1 = select_snapshots_within_budget(
867
+ snapshots.for_level(1).not_covered_by_l2,
868
+ budget: (Anima::Settings.token_budget * Anima::Settings.mneme_l1_budget_fraction).to_i
869
+ )
770
870
 
771
- l2_messages + l1_messages
871
+ sections = []
872
+ sections << format_snapshots_text(l2, label: "Long-term Memory") if l2.any?
873
+ sections << format_snapshots_text(l1, label: "Recent Memory") if l1.any?
874
+ sections.join("\n\n").presence
772
875
  end
773
876
 
774
- # Walks snapshots chronologically, selecting until the token budget is exhausted.
775
- # Always includes at least one snapshot even if it exceeds the budget, so the
776
- # agent never loses all memory context.
877
+ # Walks snapshots newest-first (by to_message_id), selecting until the
878
+ # token budget is exhausted. Always includes the newest snapshot even
879
+ # if it exceeds the budget. Returns results in chronological order
880
+ # so they read as a timeline in the system prompt.
777
881
  #
778
882
  # @param scope [ActiveRecord::Relation] snapshot scope to select from
779
883
  # @param budget [Integer] maximum tokens to include
@@ -782,91 +886,111 @@ class Session < ApplicationRecord
782
886
  selected = []
783
887
  remaining = budget
784
888
 
785
- scope.each do |snapshot|
786
- cost = snapshot.token_cost
889
+ scope.order(to_message_id: :desc).each do |snapshot|
890
+ cost = snapshot.token_count
787
891
  break if cost > remaining && selected.any?
788
892
 
789
893
  selected << snapshot
790
894
  remaining -= cost
791
895
  end
792
896
 
793
- selected
897
+ selected.reverse
794
898
  end
795
899
 
796
- # Formats a snapshot as an Anthropic user message with a memory label prefix.
900
+ # Formats a list of snapshots as a labeled section for the system prompt.
797
901
  #
798
- # @param snapshot [Snapshot]
799
- # @param label [String] human-readable label (e.g. "recent memory", "long-term memory")
800
- # @return [Hash] Anthropic message format
801
- def format_snapshot_message(snapshot, label:)
802
- {role: "user", content: "[#{label}]\n#{snapshot.text}"}
902
+ # @param snapshots_list [Array<Snapshot>]
903
+ # @param label [String] section heading
904
+ # @return [String]
905
+ def format_snapshots_text(snapshots_list, label:)
906
+ texts = snapshots_list.map(&:text)
907
+ "## #{label}\n\n#{texts.join("\n\n")}"
803
908
  end
804
909
 
805
- # Assembles pinned messages as a Goals section message for the viewport.
806
- # Only includes pinned messages whose source message has evicted from the
807
- # sliding window (same rule as snapshots no duplication with live messages).
910
+ # Assembles the context prefix: active goals snapshot + pinned messages.
911
+ # Only shown after the first eviction before that, goal events flow
912
+ # as phantom pairs in the message stream and pinned messages have not
913
+ # yet evicted.
808
914
  #
809
- # Deduplication: the first Goal referencing a message shows its truncated
810
- # display_text; subsequent Goals show a bare `message N` ID to save tokens.
915
+ # Returns a phantom tool_call/tool_result pair so the LLM sees a
916
+ # coherent goals + pins block it "recalled" via a tool invocation.
811
917
  #
812
918
  # @param first_message_id [Integer, nil] first message ID in the sliding window
813
- # @param budget [Integer] token budget for pinned messages
814
- # @return [Array<Hash>] Anthropic Messages API format (0 or 1 messages)
815
- def assemble_pinned_section_messages(first_message_id, budget:)
919
+ # @param budget [Integer] token budget for context prefix
920
+ # @return [Array<Hash>] Anthropic Messages API format (0 or 2 messages)
921
+ def assemble_context_prefix_messages(first_message_id, budget:)
816
922
  return [] unless first_message_id
923
+ return [] unless messages.where("id < ?", first_message_id).exists?
817
924
 
818
- pins = pinned_messages
819
- .includes(:message, :goals)
820
- .where("pinned_messages.message_id < ?", first_message_id)
821
- .order("pinned_messages.message_id")
822
-
823
- return [] if pins.empty?
925
+ root_goals = goals.root.active.includes(:sub_goals).order(:created_at)
926
+ return [] if root_goals.empty?
824
927
 
825
- selected = select_pins_within_budget(pins, budget)
826
- return [] if selected.empty?
827
-
828
- text = render_pinned_messages_section(selected)
829
- [{role: "user", content: "[pinned messages]\n#{text}"}]
928
+ pins_scope = pinned_messages.where("pinned_messages.message_id < ?", first_message_id)
929
+ selected_pins = select_pins_within_budget(pins_scope, budget: budget)
930
+ .includes(:message, :goals)
931
+ content = render_goal_snapshot_with_pins(root_goals, selected_pins)
932
+
933
+ # Uses session ID (not PendingMessage ID) because this snapshot is
934
+ # rebuilt from DB state on every eviction — it has no stable PM record.
935
+ uid = "goal_snapshot_#{id}"
936
+ [
937
+ {role: "assistant", content: [
938
+ {type: "tool_use", id: uid, name: PendingMessage::MELETE_GOAL_TOOL, input: {}}
939
+ ]},
940
+ {role: "user", content: [
941
+ {type: "tool_result", tool_use_id: uid, content: content}
942
+ ]}
943
+ ]
830
944
  end
831
945
 
832
- # Walks pinned messages chronologically, selecting until the token budget
833
- # is exhausted. Always includes at least one pin.
946
+ # Selects pins within a token budget using a cumulative-sum window
947
+ # function mirror of {#eviction_zone_messages} but keyed by
948
+ # +message_id+. Walks oldest-first and always anchors on the first
949
+ # pin even if it alone exceeds the budget (via
950
+ # +running_total = token_count+).
834
951
  #
835
- # @param pins [Array<PinnedMessage>]
836
- # @param budget [Integer]
837
- # @return [Array<PinnedMessage>]
838
- def select_pins_within_budget(pins, budget)
839
- selected = []
840
- remaining = budget
841
-
842
- pins.each do |pin|
843
- cost = pin.token_cost
844
- break if cost > remaining && selected.any?
845
-
846
- selected << pin
847
- remaining -= cost
848
- end
952
+ # @param scope [ActiveRecord::Relation] pin scope to select from
953
+ # @param budget [Integer] maximum tokens to include
954
+ # @return [ActiveRecord::Relation<PinnedMessage>] chronologically ordered
955
+ def select_pins_within_budget(scope, budget:)
956
+ windowed = scope.select(
957
+ "pinned_messages.*",
958
+ "SUM(pinned_messages.token_count) OVER (ORDER BY pinned_messages.message_id ASC) AS running_total"
959
+ )
849
960
 
850
- selected
961
+ PinnedMessage
962
+ .from(windowed, :pinned_messages)
963
+ .where("running_total <= ? OR running_total = token_count", budget)
964
+ .order(:message_id)
851
965
  end
852
966
 
853
- # Renders the pinned messages section grouped by Goal.
854
- # First Goal referencing a pin shows truncated text; subsequent Goals
855
- # show bare `message N` ID to avoid token-expensive repetition.
967
+ # Renders active goals with their associated pinned messages as a
968
+ # combined snapshot. Each goal shows its sub-goals and any pinned
969
+ # messages attached to it.
856
970
  #
971
+ # @param root_goals [Array<Goal>] active root goals with preloaded sub_goals
857
972
  # @param pins [Array<PinnedMessage>] selected pins with preloaded goals
858
- # @return [String] formatted section text
859
- def render_pinned_messages_section(pins)
860
- goal_pins = group_pins_by_active_goal(pins)
861
-
973
+ # @return [String] formatted goals + pins block
974
+ def render_goal_snapshot_with_pins(root_goals, pins)
975
+ pin_groups = group_pins_by_active_goal(pins)
862
976
  shown_messages = Set.new
863
- goal_pins.map { |goal, pin_list|
864
- render_goal_pins(goal, pin_list, shown_messages)
865
- }.join("\n\n")
977
+
978
+ sections = root_goals.map { |goal|
979
+ lines = [render_goal_markdown(goal)]
980
+ goal_pins = pin_groups[goal]
981
+ if goal_pins
982
+ lines << ""
983
+ goal_pins.each { |pin| lines << format_pin_line(pin, shown_messages) }
984
+ end
985
+ lines.join("\n")
986
+ }
987
+
988
+ "Current Goals\n=============\n\n#{sections.join("\n\n")}"
866
989
  end
867
990
 
868
991
  # Groups pins by their active Goals so the viewport renders
869
- # one headed section per Goal.
992
+ # one headed section per Goal. Relies on +:goals+ being eager-loaded
993
+ # on each pin — without it, +active_goal_pin_pairs+ triggers N+1.
870
994
  #
871
995
  # @param pins [Array<PinnedMessage>] pins with preloaded goals
872
996
  # @return [Hash{Goal => Array<PinnedMessage>}]
@@ -884,18 +1008,6 @@ class Session < ApplicationRecord
884
1008
  pin.goals.select(&:active?).map { |goal| [goal, pin] }
885
1009
  end
886
1010
 
887
- # Renders one Goal's pinned messages as a headed list.
888
- #
889
- # @param goal [Goal]
890
- # @param pin_list [Array<PinnedMessage>]
891
- # @param shown_messages [Set<Integer>] tracks already-rendered message IDs for dedup
892
- # @return [String]
893
- def render_goal_pins(goal, pin_list, shown_messages)
894
- lines = ["📌 #{goal.description} (id: #{goal.id})"]
895
- pin_list.each { |pin| lines << format_pin_line(pin, shown_messages) }
896
- lines.join("\n")
897
- end
898
-
899
1011
  # Formats a single pin line with deduplication: first occurrence shows
900
1012
  # truncated text, subsequent occurrences show bare message ID only.
901
1013
  #
@@ -905,109 +1017,100 @@ class Session < ApplicationRecord
905
1017
  def format_pin_line(pin, shown_messages)
906
1018
  mid = pin.message_id
907
1019
  if shown_messages.add?(mid)
908
- " message #{mid}: #{pin.display_text}"
1020
+ " 📌 message #{mid}: #{pin.display_text}"
909
1021
  else
910
- " message #{mid}"
911
- end
912
- end
913
-
914
- # Assembles recalled memory messages from passive recall results.
915
- # Recalled messages are fetched by ID and formatted as compact snippets
916
- # with session and message context for drill-down via the remember tool.
917
- #
918
- # @param budget [Integer] token budget for recall messages
919
- # @return [Array<Hash>] Anthropic Messages API format
920
- def assemble_recall_messages(budget:)
921
- return [] if recalled_message_ids.blank?
922
-
923
- recalled = Message.where(id: recalled_message_ids)
924
- .includes(:session)
925
- .index_by(&:id)
926
-
927
- snippets = []
928
- remaining = budget
929
-
930
- recalled_message_ids.each do |mid|
931
- msg = recalled[mid]
932
- next unless msg
933
-
934
- text = format_recall_snippet(msg)
935
- cost = Message.estimate_token_count(text.bytesize)
936
- break if cost > remaining && snippets.any?
937
-
938
- snippets << text
939
- remaining -= cost
940
- end
941
-
942
- return [] if snippets.empty?
943
-
944
- [{role: "user", content: "[associative recall]\n#{snippets.join("\n\n")}"}]
945
- end
946
-
947
- # Formats a recalled message as a compact snippet with enough context
948
- # for the agent to decide whether to drill down with the remember tool.
949
- #
950
- # @param msg [Message] the recalled message
951
- # @return [String] formatted snippet
952
- def format_recall_snippet(msg)
953
- session_label = msg.session.name || "session ##{msg.session_id}"
954
- content = extract_message_content(msg).to_s.truncate(Anima::Settings.recall_max_snippet_tokens * Message::BYTES_PER_TOKEN)
955
- "message #{msg.id} (#{session_label}): #{content}"
956
- end
957
-
958
- # Extracts readable content from a message's payload.
959
- #
960
- # @param msg [Message]
961
- # @return [String]
962
- def extract_message_content(msg)
963
- data = msg.payload
964
- case msg.message_type
965
- when "user_message", "agent_message", "system_message"
966
- data["content"]
967
- when "tool_call"
968
- if data["tool_name"] == Message::THINK_TOOL
969
- data.dig("tool_input", "thoughts")
970
- else
971
- "#{data["tool_name"]}(…)"
972
- end
973
- else
974
- data["content"]
1022
+ " 📌 message #{mid}"
975
1023
  end
976
1024
  end
977
1025
 
978
1026
  # Converts a chronological list of messages into Anthropic wire-format messages.
979
1027
  # Prepends a compact timestamp to each user message for LLM time awareness.
980
- # Groups consecutive tool_call messages into one assistant message and
981
- # consecutive tool_response messages into one user message.
982
1028
  #
983
- # @param msgs [Array<Message>]
984
- # @return [Array<Hash>]
1029
+ # Tool pairing uses +tool_use_id+ lookup, not message order. When a batch
1030
+ # of consecutive +tool_call+ messages is encountered, all matching
1031
+ # +tool_response+ messages are found by +tool_use_id+ and emitted as a
1032
+ # single user message immediately after the assistant message. This
1033
+ # guarantees correct API structure even when responses are persisted
1034
+ # out of order (e.g. parallel tool execution, interleaved sub-agent
1035
+ # deliveries, or promoted pending messages).
1036
+ #
1037
+ # Assumes +ensure_atomic_tool_pairs+ has already removed any unpaired
1038
+ # tool messages from the window.
1039
+ #
1040
+ # @param msgs [Array<Message>] chronologically ordered (by id), pre-filtered
1041
+ # @return [Array<Hash>] Anthropic API message format
985
1042
  def assemble_messages(msgs)
986
- msgs.each_with_object([]) do |msg, api_messages|
1043
+ response_index = build_tool_response_index(msgs)
1044
+
1045
+ result = []
1046
+ i = 0
1047
+ while i < msgs.length
1048
+ msg = msgs[i]
1049
+
987
1050
  case msg.message_type
988
1051
  when "user_message"
989
- content = "#{format_message_time(msg.timestamp)}\n#{msg.payload["content"]}"
990
- api_messages << {role: "user", content: content}
1052
+ result << {role: "user", content: "#{format_message_time(msg.timestamp)}\n#{msg.payload["content"]}"}
1053
+ i += 1
991
1054
  when "agent_message"
992
- api_messages << {role: "assistant", content: msg.payload["content"].to_s}
1055
+ result << {role: "assistant", content: msg.payload["content"].to_s}
1056
+ i += 1
993
1057
  when "tool_call"
994
- append_grouped_block(api_messages, "assistant", tool_use_block(msg.payload))
1058
+ i = assemble_tool_pair(msgs, i, response_index, result)
995
1059
  when "tool_response"
996
- append_grouped_block(api_messages, "user", tool_result_block(msg.payload))
1060
+ # Already emitted by assemble_tool_pair via tool_use_id lookup.
1061
+ # Any response still here was orphaned by viewport eviction
1062
+ # and should have been stripped by ensure_atomic_tool_pairs.
1063
+ i += 1
997
1064
  when "system_message"
998
- # Wrapped as user role with prefix — Claude API has no system role in conversation history
999
- api_messages << {role: "user", content: "[system] #{msg.payload["content"]}"}
1065
+ result << {role: "user", content: "[system] #{msg.payload["content"]}"}
1066
+ i += 1
1067
+ else
1068
+ i += 1
1000
1069
  end
1001
1070
  end
1071
+
1072
+ result
1002
1073
  end
1003
1074
 
1004
- # Groups consecutive tool blocks into a single message of the given role.
1005
- def append_grouped_block(api_messages, role, block)
1006
- prev = api_messages.last
1007
- if prev&.dig(:role) == role && prev[:content].is_a?(Array)
1008
- prev[:content] << block
1009
- else
1010
- api_messages << {role: role, content: [block]}
1075
+ # Collects a batch of consecutive tool_call messages starting at +start+,
1076
+ # emits one assistant message with all tool_use blocks, then emits one
1077
+ # user message with matching tool_result blocks found by tool_use_id.
1078
+ #
1079
+ # @param msgs [Array<Message>] the full message list
1080
+ # @param start [Integer] index of the first tool_call in the batch
1081
+ # @param response_index [Hash{String => Message}] tool_use_id → tool_response
1082
+ # @param result [Array<Hash>] accumulator for assembled API messages
1083
+ # @return [Integer] index of the first message after the batch
1084
+ def assemble_tool_pair(msgs, start, response_index, result)
1085
+ # Collect consecutive tool_calls (same LLM turn)
1086
+ batch = []
1087
+ i = start
1088
+ while i < msgs.length && msgs[i].message_type == "tool_call"
1089
+ batch << msgs[i]
1090
+ i += 1
1091
+ end
1092
+
1093
+ # Assistant message: all tool_use blocks
1094
+ result << {role: "assistant", content: batch.map { |tc| tool_use_block(tc.payload) }}
1095
+
1096
+ # User message: matching tool_result blocks, paired by tool_use_id
1097
+ tool_results = batch.filter_map do |tc|
1098
+ response = response_index[tc.tool_use_id]
1099
+ next unless response
1100
+ tool_result_block(response.payload)
1101
+ end
1102
+ result << {role: "user", content: tool_results} if tool_results.any?
1103
+
1104
+ i
1105
+ end
1106
+
1107
+ # Builds a hash mapping tool_use_id → tool_response Message for O(1) lookup.
1108
+ #
1109
+ # @param msgs [Array<Message>]
1110
+ # @return [Hash{String => Message}]
1111
+ def build_tool_response_index(msgs)
1112
+ msgs.each_with_object({}) do |msg, idx|
1113
+ idx[msg.tool_use_id] = msg if msg.message_type == "tool_response"
1011
1114
  end
1012
1115
  end
1013
1116
 
@@ -1046,13 +1149,4 @@ class Session < ApplicationRecord
1046
1149
  def now_ns
1047
1150
  Time.current.to_ns
1048
1151
  end
1049
-
1050
- # Delegates to {Message#estimate_tokens} for messages not yet counted
1051
- # by the background job.
1052
- #
1053
- # @param msg [Message]
1054
- # @return [Integer] at least 1
1055
- def estimate_tokens(msg)
1056
- msg.estimate_tokens
1057
- end
1058
1152
  end