anima-core 1.4.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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +18 -20
  3. data/README.md +61 -95
  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 +13 -2
  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 +21 -10
  30. data/app/models/message.rb +47 -36
  31. data/app/models/pending_message.rb +276 -29
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +468 -432
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +17 -4
  36. data/config/application.rb +1 -0
  37. data/config/initializers/event_subscribers.rb +71 -4
  38. data/config/initializers/inflections.rb +3 -1
  39. data/db/cable_structure.sql +3 -3
  40. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  41. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  42. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  43. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  44. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  45. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  46. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  47. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  48. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  49. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  50. data/db/queue_structure.sql +13 -13
  51. data/db/structure.sql +44 -31
  52. data/lib/agents/registry.rb +1 -1
  53. data/lib/anima/settings.rb +7 -33
  54. data/lib/anima/version.rb +1 -1
  55. data/lib/events/authentication_required.rb +24 -0
  56. data/lib/events/bounce_back.rb +4 -4
  57. data/lib/events/eviction_completed.rb +28 -0
  58. data/lib/events/goal_created.rb +28 -0
  59. data/lib/events/goal_updated.rb +32 -0
  60. data/lib/events/llm_responded.rb +35 -0
  61. data/lib/events/message_created.rb +27 -0
  62. data/lib/events/message_updated.rb +25 -0
  63. data/lib/events/session_state_changed.rb +30 -0
  64. data/lib/events/skill_activated.rb +28 -0
  65. data/lib/events/start_melete.rb +36 -0
  66. data/lib/events/start_mneme.rb +33 -0
  67. data/lib/events/start_processing.rb +32 -0
  68. data/lib/events/subagent_evicted.rb +31 -0
  69. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  70. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  71. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  72. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  73. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  74. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  75. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  76. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  77. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  78. data/lib/events/subscribers/persister.rb +6 -8
  79. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  80. data/lib/events/subscribers/subagent_message_router.rb +26 -29
  81. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  82. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  83. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  84. data/lib/events/tool_executed.rb +34 -0
  85. data/lib/events/workflow_activated.rb +27 -0
  86. data/lib/llm/client.rb +41 -201
  87. data/lib/mcp/client_manager.rb +41 -46
  88. data/lib/mcp/stdio_transport.rb +9 -5
  89. data/lib/{analytical_brain → melete}/runner.rb +63 -68
  90. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +1 -1
  91. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +2 -2
  92. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  93. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +3 -3
  94. data/lib/{analytical_brain → melete}/tools/goal_messaging.rb +4 -3
  95. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +2 -2
  96. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  97. data/lib/{analytical_brain → melete}/tools/set_goal.rb +1 -1
  98. data/lib/{analytical_brain → melete}/tools/update_goal.rb +4 -4
  99. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  100. data/lib/mneme/base_runner.rb +121 -0
  101. data/lib/mneme/l2_runner.rb +14 -20
  102. data/lib/mneme/recall_runner.rb +132 -0
  103. data/lib/mneme/runner.rb +118 -171
  104. data/lib/mneme/search.rb +104 -62
  105. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  106. data/lib/mneme/tools/save_snapshot.rb +2 -10
  107. data/lib/mneme/tools/surface_memory.rb +89 -0
  108. data/lib/mneme.rb +11 -5
  109. data/lib/shell_session.rb +287 -612
  110. data/lib/skills/definition.rb +2 -2
  111. data/lib/skills/registry.rb +1 -1
  112. data/lib/tools/base.rb +16 -0
  113. data/lib/tools/bash.rb +25 -57
  114. data/lib/tools/edit.rb +2 -0
  115. data/lib/tools/read.rb +2 -0
  116. data/lib/tools/registry.rb +79 -3
  117. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  118. data/lib/tools/spawn_specialist.rb +16 -10
  119. data/lib/tools/spawn_subagent.rb +20 -14
  120. data/lib/tools/subagent_prompts.rb +4 -4
  121. data/lib/tools/think.rb +1 -1
  122. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  123. data/lib/tools/write.rb +2 -0
  124. data/lib/tui/app.rb +5 -4
  125. data/lib/tui/braille_spinner.rb +7 -7
  126. data/lib/tui/decorators/base_decorator.rb +24 -3
  127. data/lib/tui/message_store.rb +93 -44
  128. data/lib/tui/screens/chat.rb +94 -20
  129. data/lib/tui/settings.rb +9 -2
  130. data/lib/workflows/definition.rb +3 -3
  131. data/lib/workflows/registry.rb +1 -1
  132. data/skills/github.md +38 -0
  133. data/templates/config.toml +4 -23
  134. data/workflows/review_pr.md +18 -14
  135. metadata +86 -28
  136. data/app/jobs/agent_request_job.rb +0 -199
  137. data/app/jobs/analytical_brain_job.rb +0 -33
  138. data/app/jobs/count_message_tokens_job.rb +0 -39
  139. data/app/jobs/passive_recall_job.rb +0 -24
  140. data/app/models/concerns/message/broadcasting.rb +0 -86
  141. data/lib/agent_loop.rb +0 -215
  142. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -40
  143. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -35
  144. data/lib/events/agent_message.rb +0 -25
  145. data/lib/events/subscribers/message_collector.rb +0 -64
  146. data/lib/events/tool_call.rb +0 -31
  147. data/lib/events/tool_response.rb +0 -33
  148. data/lib/mneme/compressed_viewport.rb +0 -204
  149. data/lib/mneme/passive_recall.rb +0 -138
@@ -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,75 +73,55 @@ 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?
93
-
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)
98
-
99
- AnalyticalBrainJob.perform_later(id)
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
100
125
  end
101
126
 
102
127
  # Token budget appropriate for this session type.
@@ -106,157 +131,176 @@ class Session < ApplicationRecord
106
131
  sub_agent? ? Anima::Settings.subagent_token_budget : Anima::Settings.token_budget
107
132
  end
108
133
 
109
- # Returns the messages currently visible in the LLM context window.
110
- # Walks messages newest-first and includes them until the token budget
111
- # is exhausted. Messages are full-size or excluded entirely.
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.
112
139
  #
113
- # Pending messages live in a separate table ({PendingMessage}) and never
114
- # appear in this viewport they are promoted to real messages before
115
- # the agent processes them.
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.
116
145
  #
117
146
  # @param token_budget [Integer] maximum tokens to include (positive)
118
- # @return [Array<Message>] chronologically ordered
147
+ # @return [ActiveRecord::Relation<Message>] chronologically ordered by id
119
148
  def viewport_messages(token_budget: effective_token_budget)
120
- select_messages(own_message_scope, budget: token_budget)
121
- end
149
+ scope = messages
150
+ scope = scope.where("messages.id >= ?", mneme_boundary_message_id) if mneme_boundary_message_id
122
151
 
123
- # Recalculates the viewport and returns IDs of messages evicted since the
124
- # last snapshot. Updates the stored viewport_message_ids atomically.
125
- # Piggybacks on message broadcasts to notify clients which messages left
126
- # the LLM's context window.
127
- #
128
- # @return [Array<Integer>] IDs of messages no longer in the viewport
129
- def recalculate_viewport!
130
- new_ids = viewport_messages.map(&:id)
131
- old_ids = viewport_message_ids
152
+ windowed = scope.select(
153
+ "messages.*",
154
+ "SUM(token_count) OVER (ORDER BY id DESC) AS running_total"
155
+ )
132
156
 
133
- evicted = old_ids - new_ids
134
- update_column(:viewport_message_ids, new_ids) if old_ids != new_ids
135
- evicted
157
+ Message
158
+ .from(windowed, :messages)
159
+ .where("running_total <= ? OR running_total = token_count", token_budget)
160
+ .order(:id)
136
161
  end
137
162
 
138
- # Overwrites the viewport snapshot without computing evictions.
139
- # Used when transmitting or broadcasting a full viewport refresh,
140
- # where eviction notifications are unnecessary (clients clear their
141
- # store first).
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.
142
167
  #
143
- # @param ids [Array<Integer>] message IDs now in the viewport
144
- # @return [void]
145
- def snapshot_viewport!(ids)
146
- update_column(:viewport_message_ids, ids)
168
+ # Mirror of {#viewport_messages} but walks oldest-first from the boundary
169
+ # instead of newest-first from the tail.
170
+ #
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)
147
188
  end
148
189
 
149
- # Returns skill names whose recalled content is currently visible in the
150
- # viewport. Used by the analytical brain for deduplication — skills already
151
- # in the viewport are excluded from the activation catalog.
190
+ # Names of skills currently present in the viewport as
191
+ # `from_melete_skill` phantom tool_call messages, in activation order.
152
192
  #
153
- # @return [Set<String>] skill names present in the viewport
193
+ # @return [Array<String>] skill names in the viewport, activation order
154
194
  def skills_in_viewport
155
- recalled_sources_in_viewport("skill")
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
156
199
  end
157
200
 
158
- # Returns the workflow name currently visible in the viewport, if any.
159
- # Only one workflow can be active at a time, so we return the first match.
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.
160
204
  #
161
- # @return [String, nil] workflow name present in the viewport
205
+ # @return [String, nil] workflow name in the viewport, or nil
162
206
  def workflow_in_viewport
163
- recalled_sources_in_viewport("workflow").first
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')"))
164
211
  end
165
212
 
166
- # Returns the system prompt for this session.
167
- # Sub-agent sessions use their stored prompt plus active skills and
168
- # the pinned task. Main sessions assemble a full system prompt from
169
- # soul and snapshots. Skills, workflows, and goals are injected as
170
- # phantom tool_use/tool_result pairs in the message stream (not here)
171
- # to keep the system prompt stable for prompt caching. Environment
172
- # awareness flows through Bash tool responses.
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
222
+ end
223
+
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).
173
227
  #
174
- # Sub-agent sessions still include expertise inline — they're short-lived
175
- # and don't benefit from prompt caching.
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
232
+ end
233
+
234
+ # Returns the system prompt for this session.
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.
176
241
  #
177
242
  # @return [String, nil] the system prompt text, or nil when nothing to inject
178
243
  def system_prompt
179
244
  if sub_agent?
180
- [prompt, assemble_expertise_section, assemble_task_section].compact.join("\n\n")
245
+ [prompt, assemble_task_section].compact.join("\n\n")
181
246
  else
182
247
  assemble_system_prompt
183
248
  end
184
249
  end
185
250
 
186
- # Activates a skill on this session. Validates the skill exists in the
187
- # registry, updates active_skills, and enqueues the skill content as a
188
- # {PendingMessage} so it enters the conversation as a phantom
189
- # tool_use/tool_result pair through the normal promotion flow.
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.
190
255
  #
191
256
  # @param skill_name [String] name of the skill to activate
192
257
  # @return [Skills::Definition] the activated skill
193
258
  # @raise [Skills::InvalidDefinitionError] if skill not found in registry
194
- # @raise [ActiveRecord::RecordInvalid] if save fails
195
259
  def activate_skill(skill_name)
196
260
  definition = Skills::Registry.instance.find(skill_name)
197
261
  raise Skills::InvalidDefinitionError, "Unknown skill: #{skill_name}" unless definition
198
-
199
262
  return definition if active_skills.include?(skill_name)
200
263
 
201
- self.active_skills = active_skills + [skill_name]
202
- save!
203
264
  enqueue_recall_message("skill", skill_name, definition.content)
265
+ Events::Bus.emit(Events::SkillActivated.new(session_id: id, skill_name: skill_name))
204
266
  definition
205
267
  end
206
268
 
207
- # Deactivates a skill on this session. Removes it from active_skills and persists.
208
- # The skill's recalled message stays in the conversation and evicts naturally.
209
- #
210
- # @param skill_name [String] name of the skill to deactivate
211
- # @return [void]
212
- def deactivate_skill(skill_name)
213
- return unless active_skills.include?(skill_name)
214
-
215
- self.active_skills = active_skills - [skill_name]
216
- save!
217
- end
218
-
219
- # Activates a workflow on this session. Validates the workflow exists in the
220
- # registry, sets it as the active workflow, and enqueues the workflow content
221
- # as a {PendingMessage}. Only one workflow can be active at a time —
222
- # 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.
223
274
  #
224
275
  # @param workflow_name [String] name of the workflow to activate
225
276
  # @return [Workflows::Definition] the activated workflow
226
277
  # @raise [Workflows::InvalidDefinitionError] if workflow not found in registry
227
- # @raise [ActiveRecord::RecordInvalid] if save fails
228
278
  def activate_workflow(workflow_name)
229
279
  definition = Workflows::Registry.instance.find(workflow_name)
230
280
  raise Workflows::InvalidDefinitionError, "Unknown workflow: #{workflow_name}" unless definition
231
-
232
281
  return definition if active_workflow == workflow_name
233
282
 
234
- self.active_workflow = workflow_name
235
- save!
236
283
  enqueue_recall_message("workflow", workflow_name, definition.content)
284
+ Events::Bus.emit(Events::WorkflowActivated.new(session_id: id, workflow_name: workflow_name))
237
285
  definition
238
286
  end
239
287
 
240
- # Deactivates the current workflow on this session.
241
- # The workflow's recalled message stays in the conversation and evicts naturally.
242
- #
243
- # @return [void]
244
- def deactivate_workflow
245
- return unless active_workflow.present?
246
-
247
- self.active_workflow = nil
248
- save!
249
- end
250
-
251
- # Assembles the system prompt: version preamble, soul, and snapshots.
252
- # Skills, workflows, goals, and environment awareness flow through the
253
- # message stream and tool responses, keeping the system prompt stable
254
- # for prompt caching.
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.
255
293
  #
256
294
  # @return [String] composed system prompt
257
295
  def assemble_system_prompt
258
- [assemble_version_preamble, assemble_soul_section, assemble_snapshots_section]
259
- .compact.join("\n\n")
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")
260
304
  end
261
305
 
262
306
  # Serializes non-evicted goals as a lightweight summary for ActionCable
@@ -293,7 +337,7 @@ class Session < ApplicationRecord
293
337
  pinned_budget = (token_budget * Anima::Settings.mneme_pinned_budget_fraction).to_i
294
338
  sliding_budget -= pinned_budget
295
339
 
296
- window = viewport_messages(token_budget: sliding_budget)
340
+ window = viewport_messages(token_budget: sliding_budget).to_a
297
341
  first_message_id = window.first&.id
298
342
 
299
343
  prefix = assemble_context_prefix_messages(first_message_id, budget: pinned_budget)
@@ -342,72 +386,72 @@ class Session < ApplicationRecord
342
386
  healed
343
387
  end
344
388
 
345
- # Delivers a user message respecting the session's processing state.
346
- #
347
- # When idle, persists the message directly and enqueues {AgentRequestJob}
348
- # to process it. When mid-turn ({#processing?}), stages the message as
349
- # a {PendingMessage} in a separate table it gets no message ID until
350
- # promoted, so it can never interleave with tool_call/tool_response pairs.
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+.
351
396
  #
352
397
  # @param content [String] message text (raw, without attribution)
353
398
  # @param source_type [String] origin type: "user" (default) or "subagent"
354
399
  # @param source_name [String, nil] sub-agent nickname (required when source_type is "subagent")
355
- # @param bounce_back [Boolean] when true, passes +message_id+ to the job
356
- # so failed LLM delivery triggers a {Events::BounceBack} (used by
357
- # {SessionChannel#speak} for immediate-display messages)
358
- # @return [void]
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]
359
404
  def enqueue_user_message(content, source_type: "user", source_name: nil, bounce_back: false)
360
- if processing?
361
- pending_messages.create!(content: content, source_type: source_type, source_name: source_name)
362
- else
363
- display = if source_type == "subagent"
364
- format(Tools::ResponseTruncator::ATTRIBUTION_FORMAT, source_name, content)
365
- else
366
- content
367
- end
368
- msg = create_user_message(display)
369
- job_args = bounce_back ? {message_id: msg.id} : {}
370
- AgentRequestJob.perform_later(id, **job_args)
371
- end
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
+ )
372
413
  end
373
414
 
374
- # Promotes a phantom pair pending message into a tool_call/tool_response pair.
375
- # These persist as real Message records and ride the conveyor belt.
376
- #
377
- # @param pm [PendingMessage] phantom pair pending message
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
378
434
  # @return [void]
379
- def promote_phantom_pair!(pm)
380
- tool_name = pm.phantom_tool_name
381
- tool_input = pm.phantom_tool_input
382
- uid = "#{tool_name}_#{pm.id}"
383
- now = now_ns
384
-
385
- messages.create!(
386
- message_type: "tool_call",
387
- tool_use_id: uid,
388
- payload: {"tool_name" => tool_name, "tool_use_id" => uid,
389
- "tool_input" => tool_input.stringify_keys,
390
- "content" => pm.display_content.lines.first.chomp},
391
- timestamp: now,
392
- token_count: Mneme::PassiveRecall::TOOL_PAIR_OVERHEAD_TOKENS
393
- )
394
-
395
- messages.create!(
396
- message_type: "tool_response",
397
- tool_use_id: uid,
398
- payload: {"tool_name" => tool_name, "tool_use_id" => uid,
399
- "content" => pm.content, "success" => true},
400
- timestamp: now,
401
- token_count: Message.estimate_token_count(pm.content.bytesize)
402
- )
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
+ ))
403
447
  end
404
448
 
405
- # Persists a user message directly, bypassing the pending queue.
406
- #
407
- # Used by {#enqueue_user_message} (idle path), {AgentLoop#run},
408
- # and sub-agent spawn tools ({Tools::SpawnSubagent}, {Tools::SpawnSpecialist})
409
- # because the global {Events::Subscribers::Persister} skips non-pending user
410
- # 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.
411
455
  #
412
456
  # @param content [String] user message text
413
457
  # @param source_type [String, nil] origin type (e.g. "skill", "workflow")
@@ -426,43 +470,11 @@ class Session < ApplicationRecord
426
470
  )
427
471
  end
428
472
 
429
- # Promotes all pending messages into the conversation history.
430
- # Each {PendingMessage} is atomically deleted and replaced with a real
431
- # {Message} — the new message gets the next auto-increment ID,
432
- # naturally placing it after any tool_call/tool_response pairs that
433
- # were persisted while the message was waiting.
434
- #
435
- # Returns a hash with two keys:
436
- # - +:texts+ — plain content strings for user messages (injected as text blocks
437
- # within the current tool_results turn)
438
- # - +:pairs+ — synthetic tool_use/tool_result message hashes for phantom pair
439
- # types (appended as new conversation turns)
440
- #
441
- # @return [Hash{Symbol => Array}] promoted messages split by injection strategy
442
- def promote_pending_messages!
443
- texts = []
444
- pairs = []
445
- pending_messages.find_each do |pm|
446
- transaction do
447
- if pm.phantom_pair?
448
- promote_phantom_pair!(pm)
449
- else
450
- create_user_message(pm.display_content, source_type: pm.source_type, source_name: pm.source_name)
451
- end
452
- pm.destroy!
453
- end
454
- if pm.phantom_pair?
455
- pairs.concat(pm.to_llm_messages)
456
- else
457
- texts << pm.content
458
- end
459
- end
460
- {texts: texts, pairs: pairs}
461
- end
462
-
463
473
  # Broadcasts child session list to all clients subscribed to the parent
464
- # session. Called when a child session is created or its processing state
465
- # 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.
466
478
  #
467
479
  # Queries children via FK directly (avoids loading the parent record) and
468
480
  # selects only the columns needed for the HUD payload.
@@ -471,44 +483,111 @@ class Session < ApplicationRecord
471
483
  def broadcast_children_update_to_parent
472
484
  return unless parent_session_id
473
485
 
474
- children = Session.where(parent_session_id: parent_session_id)
486
+ children = Session.where(parent_session_id: parent_session_id, hud_visible: true)
475
487
  .order(:created_at)
476
- .select(:id, :name, :processing)
488
+ .select(:id, :name, :aasm_state)
477
489
  ActionCable.server.broadcast("session_#{parent_session_id}", {
478
490
  "action" => "children_updated",
479
491
  "session_id" => parent_session_id,
480
492
  "children" => children.map { |child|
481
- state = child.processing? ? "llm_generating" : "idle"
482
- {"id" => child.id, "name" => child.name, "processing" => child.processing?, "session_state" => state}
493
+ {"id" => child.id, "name" => child.name, "session_state" => child.aasm_state}
483
494
  }
484
495
  })
485
496
  end
486
497
 
487
- # Broadcasts the session's current processing state to all subscribed
488
- # clients. Stateless no storage, pure broadcast. The TUI uses this to
489
- # drive the braille spinner animation and sub-agent HUD icons.
490
- #
491
- # Payload broadcast to +session_{id}+:
492
- # {"action" => "session_state", "state" => state, "session_id" => id}
493
- # # 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.
494
555
  #
495
- # For sub-agents, also broadcasts +child_state+ to the parent stream:
496
- # {"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.
497
566
  #
498
- # @param state [String] one of "idle", "llm_generating", "tool_executing", "interrupting"
499
- # @param tool [String, nil] tool name when state is "tool_executing"
500
567
  # @return [void]
501
- def broadcast_session_state(state, tool: nil)
502
- payload = {"action" => "session_state", "state" => state, "session_id" => id}
503
- payload["tool"] = tool if tool
504
- ActionCable.server.broadcast("session_#{id}", payload)
568
+ def clear_interrupt_flag_if_idle
569
+ return unless idle?
570
+ return unless interrupt_requested?
505
571
 
506
- # Notify the parent's stream so the HUD updates child state icons
507
- # without requiring a full children_updated query.
508
- return unless parent_session_id
572
+ update_column(:interrupt_requested, false)
573
+ end
574
+
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?
509
589
 
510
- parent_payload = payload.merge("action" => "child_state", "child_id" => id)
511
- ActionCable.server.broadcast("session_#{parent_session_id}", parent_payload)
590
+ pending_messages.ordered_for_drain.first&.route_to_event_bus
512
591
  end
513
592
 
514
593
  # Broadcasts the full LLM debug context to debug-mode TUI clients.
@@ -524,6 +603,25 @@ class Session < ApplicationRecord
524
603
  ActionCable.server.broadcast("session_#{id}", self.class.system_prompt_payload(system, tools: tools))
525
604
  end
526
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
+
527
625
  # Returns the deterministic tool schemas for this session's type and
528
626
  # granted_tools configuration. Standard and spawn tools are static
529
627
  # class-level definitions — no ShellSession or registry needed.
@@ -532,22 +630,7 @@ class Session < ApplicationRecord
532
630
  #
533
631
  # @return [Array<Hash>] tool schema hashes matching Anthropic tools API format
534
632
  def tool_schemas
535
- tools = if granted_tools
536
- granted = granted_tools.filter_map { |name| AgentLoop::STANDARD_TOOLS_BY_NAME[name] }
537
- (AgentLoop::ALWAYS_GRANTED_TOOLS + granted).uniq
538
- else
539
- AgentLoop::STANDARD_TOOLS.dup
540
- end
541
-
542
- unless sub_agent?
543
- tools.push(Tools::SpawnSubagent, Tools::SpawnSpecialist, Tools::OpenIssue)
544
- end
545
-
546
- if sub_agent?
547
- tools.push(Tools::MarkGoalCompleted)
548
- end
549
-
550
- tools.map(&:schema)
633
+ resolved_tool_classes.map(&:schema)
551
634
  end
552
635
 
553
636
  # Builds the system prompt payload for debug mode transmission.
@@ -559,9 +642,8 @@ class Session < ApplicationRecord
559
642
  # @param tools [Array<Hash>, nil] tool schemas
560
643
  # @return [Hash] payload with type, rendered debug content, and token estimate
561
644
  def self.system_prompt_payload(prompt, tools: nil)
562
- total_bytes = prompt.bytesize
563
- total_bytes += tools.to_json.bytesize if tools&.any?
564
- 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)
565
647
 
566
648
  debug = {role: :system_prompt, content: prompt, tokens: tokens, estimated: true}
567
649
  debug[:tools] = tools if tools&.any?
@@ -575,33 +657,40 @@ class Session < ApplicationRecord
575
657
 
576
658
  private
577
659
 
578
- # Finds recalled skill/workflow source names in the current viewport.
579
- # Scans viewport messages for user_messages tagged with the given source_type.
580
- #
581
- # @param source_type [String] "skill" or "workflow"
582
- # @return [Set<String>] source names present in the viewport
583
- def recalled_sources_in_viewport(source_type)
584
- ids = viewport_message_ids
585
- return Set.new if ids.empty?
586
-
587
- messages
588
- .where(id: ids, message_type: "user_message")
589
- .where("json_extract(payload, '$.source_type') = ?", source_type)
590
- .pluck(Arel.sql("json_extract(payload, '$.source_name')"))
591
- .to_set
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)
592
671
  end
593
672
 
594
- # Enqueues a recalled skill or workflow as a {PendingMessage}.
595
- # Always goes through the pending queue because the analytical brain
596
- # only runs during processing. The message enters the conversation
597
- # through the normal promotion flow as a phantom tool_use/tool_result pair.
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.
598
676
  #
599
677
  # @param source_type [String] "skill" or "workflow"
600
678
  # @param source_name [String] skill or workflow name
601
679
  # @param content [String] definition content to recall
602
680
  # @return [PendingMessage] the created pending message
603
681
  def enqueue_recall_message(source_type, source_name, content)
604
- pending_messages.create!(content: content, source_type: source_type, source_name: source_name)
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
+ )
605
694
  end
606
695
 
607
696
  # One-line version preamble so the agent knows its own version.
@@ -627,25 +716,64 @@ class Session < ApplicationRecord
627
716
  File.read(path).strip
628
717
  end
629
718
 
630
- # Assembles the expertise section of the system prompt from active skills
631
- # and the active workflow. Both are injected into the same "Your Expertise"
632
- # 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.
633
723
  #
634
- # @return [String, nil] expertise section, or nil when nothing is active
635
- def assemble_expertise_section
636
- sections = active_skills.filter_map do |skill_name|
637
- definition = Skills::Registry.instance.find(skill_name)
638
- format_expertise_section(definition, skill_name)
639
- end
724
+ # @return [String] sisters section
725
+ def assemble_sisters_section
726
+ <<~SISTERS.strip
727
+ ## Your Sisters
728
+
729
+ You don't work alone. Two muses share the conversation with you, and their work arrives as tool responses prefixed `from_`:
640
730
 
641
- if active_workflow.present?
642
- definition = Workflows::Registry.instance.find(active_workflow)
643
- sections << format_expertise_section(definition, active_workflow) if definition
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`.
733
+
734
+ Sub-agents you spawn arrive the same way, named after whoever sent them — `from_sleuth`, `from_scout`, and so on.
735
+
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
738
+ end
739
+
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.
743
+ #
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
644
749
  end
750
+ return if menu.empty?
645
751
 
646
- return if sections.empty?
752
+ "## Available Tools\n\n#{menu.join("\n")}"
753
+ end
647
754
 
648
- "## Your Expertise\n\nYou know this deeply. Now's your chance to put it to work.\n\n#{sections.join("\n\n")}"
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.
759
+ #
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?
764
+
765
+ "## Tool Guidelines\n\n#{bullets.join("\n")}"
766
+ end
767
+
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)
649
777
  end
650
778
 
651
779
  # Assembles the task section for sub-agent system prompts.
@@ -685,22 +813,6 @@ class Session < ApplicationRecord
685
813
  lines.join("\n")
686
814
  end
687
815
 
688
- # Formats a definition (skill or workflow) as a Markdown section for the
689
- # expertise prompt. Extracts the first Markdown heading from content for
690
- # the section title; falls back to the definition name when content has
691
- # no heading.
692
- #
693
- # @param definition [Skills::Definition, Workflows::Definition, nil] the definition to format
694
- # @param fallback_name [String] name to use if content has no heading
695
- # @return [String, nil] formatted section, or nil if definition is nil
696
- def format_expertise_section(definition, fallback_name)
697
- return unless definition
698
-
699
- content = definition.content
700
- heading = content.lines.first&.sub(/^#+ /, "")&.strip || fallback_name
701
- "### #{heading}\n\n#{content}"
702
- end
703
-
704
816
  # Broadcasts a name change to all clients subscribed to this session.
705
817
  # Triggered by after_update_commit so clients see name updates in real time.
706
818
  #
@@ -713,66 +825,6 @@ class Session < ApplicationRecord
713
825
  })
714
826
  end
715
827
 
716
- # Broadcasts active skill changes to all clients subscribed to this session.
717
- # Triggered by after_update_commit so the TUI info panel updates reactively.
718
- #
719
- # @return [void]
720
- def broadcast_active_skills_update
721
- ActionCable.server.broadcast("session_#{id}", {
722
- "action" => "active_skills_updated",
723
- "session_id" => id,
724
- "active_skills" => active_skills
725
- })
726
- end
727
-
728
- # Broadcasts active workflow change to all clients subscribed to this session.
729
- # Triggered by after_update_commit so the TUI info panel updates reactively.
730
- #
731
- # @return [void]
732
- def broadcast_active_workflow_update
733
- ActionCable.server.broadcast("session_#{id}", {
734
- "action" => "active_workflow_updated",
735
- "session_id" => id,
736
- "active_workflow" => active_workflow
737
- })
738
- end
739
-
740
- # Scopes own messages for viewport assembly.
741
- # Starts from the Mneme boundary (inclusive) — older messages have been
742
- # compressed into snapshots and no longer participate in the viewport.
743
- # @return [ActiveRecord::Relation]
744
- def own_message_scope
745
- scope = messages.context_messages
746
- scope = scope.where("messages.id >= ?", mneme_boundary_message_id) if mneme_boundary_message_id
747
- scope
748
- end
749
-
750
- # Walks messages newest-first, selecting until the token budget is exhausted.
751
- # Always includes at least the newest message even if it exceeds budget.
752
- #
753
- # @param scope [ActiveRecord::Relation] message scope to select from
754
- # @param budget [Integer] maximum tokens to include
755
- # @return [Array<Message>] chronologically ordered
756
- def select_messages(scope, budget:)
757
- selected = []
758
- remaining = budget
759
-
760
- scope.reorder(id: :desc).each do |msg|
761
- cost = message_token_cost(msg)
762
- break if cost > remaining && selected.any?
763
-
764
- selected << msg
765
- remaining -= cost
766
- end
767
-
768
- selected.reverse
769
- end
770
-
771
- # @return [Integer] token cost, using cached count or heuristic estimate
772
- def message_token_cost(msg)
773
- (msg.token_count > 0) ? msg.token_count : estimate_tokens(msg)
774
- end
775
-
776
828
  # Ensures every tool_call in the message list has a matching tool_response
777
829
  # (and vice versa) by removing unpaired messages. The Anthropic API requires
778
830
  # every tool_use block to have a tool_result — a missing partner causes
@@ -800,25 +852,20 @@ class Session < ApplicationRecord
800
852
  end
801
853
 
802
854
  # Assembles L1/L2 snapshots as a system prompt section.
803
- # Snapshots are visible when their source messages precede the Mneme boundary
804
- # (compressed in a previous run). Between Mneme runs this section is frozen,
805
- # making it cache-friendly.
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.
806
859
  #
807
860
  # @return [String, nil] formatted snapshot text for the system prompt, or nil
808
861
  def assemble_snapshots_section
809
- reference_id = mneme_boundary_message_id || viewport_message_ids.first
810
- return unless reference_id
811
-
812
- l2_budget = (Anima::Settings.token_budget * Anima::Settings.mneme_l2_budget_fraction).to_i
813
- l1_budget = (Anima::Settings.token_budget * Anima::Settings.mneme_l1_budget_fraction).to_i
814
-
815
862
  l2 = select_snapshots_within_budget(
816
- snapshots.for_level(2).source_messages_evicted(reference_id).chronological,
817
- budget: l2_budget
863
+ snapshots.for_level(2),
864
+ budget: (Anima::Settings.token_budget * Anima::Settings.mneme_l2_budget_fraction).to_i
818
865
  )
819
866
  l1 = select_snapshots_within_budget(
820
- snapshots.for_level(1).not_covered_by_l2.source_messages_evicted(reference_id).chronological,
821
- budget: l1_budget
867
+ snapshots.for_level(1).not_covered_by_l2,
868
+ budget: (Anima::Settings.token_budget * Anima::Settings.mneme_l1_budget_fraction).to_i
822
869
  )
823
870
 
824
871
  sections = []
@@ -827,9 +874,10 @@ class Session < ApplicationRecord
827
874
  sections.join("\n\n").presence
828
875
  end
829
876
 
830
- # Walks snapshots chronologically, selecting until the token budget is exhausted.
831
- # Always includes at least one snapshot even if it exceeds the budget, so the
832
- # 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.
833
881
  #
834
882
  # @param scope [ActiveRecord::Relation] snapshot scope to select from
835
883
  # @param budget [Integer] maximum tokens to include
@@ -838,15 +886,15 @@ class Session < ApplicationRecord
838
886
  selected = []
839
887
  remaining = budget
840
888
 
841
- scope.each do |snapshot|
842
- cost = snapshot.token_cost
889
+ scope.order(to_message_id: :desc).each do |snapshot|
890
+ cost = snapshot.token_count
843
891
  break if cost > remaining && selected.any?
844
892
 
845
893
  selected << snapshot
846
894
  remaining -= cost
847
895
  end
848
896
 
849
- selected
897
+ selected.reverse
850
898
  end
851
899
 
852
900
  # Formats a list of snapshots as a labeled section for the system prompt.
@@ -877,12 +925,9 @@ class Session < ApplicationRecord
877
925
  root_goals = goals.root.active.includes(:sub_goals).order(:created_at)
878
926
  return [] if root_goals.empty?
879
927
 
880
- pins = pinned_messages
928
+ pins_scope = pinned_messages.where("pinned_messages.message_id < ?", first_message_id)
929
+ selected_pins = select_pins_within_budget(pins_scope, budget: budget)
881
930
  .includes(:message, :goals)
882
- .where("pinned_messages.message_id < ?", first_message_id)
883
- .order("pinned_messages.message_id")
884
-
885
- selected_pins = select_pins_within_budget(pins, budget)
886
931
  content = render_goal_snapshot_with_pins(root_goals, selected_pins)
887
932
 
888
933
  # Uses session ID (not PendingMessage ID) because this snapshot is
@@ -890,7 +935,7 @@ class Session < ApplicationRecord
890
935
  uid = "goal_snapshot_#{id}"
891
936
  [
892
937
  {role: "assistant", content: [
893
- {type: "tool_use", id: uid, name: PendingMessage::RECALL_GOAL_TOOL, input: {}}
938
+ {type: "tool_use", id: uid, name: PendingMessage::MELETE_GOAL_TOOL, input: {}}
894
939
  ]},
895
940
  {role: "user", content: [
896
941
  {type: "tool_result", tool_use_id: uid, content: content}
@@ -898,25 +943,25 @@ class Session < ApplicationRecord
898
943
  ]
899
944
  end
900
945
 
901
- # Walks pinned messages chronologically, selecting until the token budget
902
- # 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+).
903
951
  #
904
- # @param pins [Array<PinnedMessage>]
905
- # @param budget [Integer]
906
- # @return [Array<PinnedMessage>]
907
- def select_pins_within_budget(pins, budget)
908
- selected = []
909
- remaining = budget
910
-
911
- pins.each do |pin|
912
- cost = pin.token_cost
913
- break if cost > remaining && selected.any?
914
-
915
- selected << pin
916
- remaining -= cost
917
- 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
+ )
918
960
 
919
- selected
961
+ PinnedMessage
962
+ .from(windowed, :pinned_messages)
963
+ .where("running_total <= ? OR running_total = token_count", budget)
964
+ .order(:message_id)
920
965
  end
921
966
 
922
967
  # Renders active goals with their associated pinned messages as a
@@ -1104,13 +1149,4 @@ class Session < ApplicationRecord
1104
1149
  def now_ns
1105
1150
  Time.current.to_ns
1106
1151
  end
1107
-
1108
- # Delegates to {Message#estimate_tokens} for messages not yet counted
1109
- # by the background job.
1110
- #
1111
- # @param msg [Message]
1112
- # @return [Integer] at least 1
1113
- def estimate_tokens(msg)
1114
- msg.estimate_tokens
1115
- end
1116
1152
  end