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
@@ -1,35 +1,420 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # A user message waiting to enter a session's conversation history.
3
+ # A message waiting to enter a session's conversation history.
4
4
  # Pending messages live in their own table — they are NOT part of the
5
5
  # message stream and have no database ID that could interleave with
6
6
  # tool_call/tool_response pairs.
7
7
  #
8
- # Created when a user sends a message while the session is processing.
9
- # Promoted to a real {Message} (delete + create in transaction) when
10
- # the current agent loop completes, giving the new message an ID that
11
- # naturally follows the tool batch.
8
+ # Entry point of the event-driven drain pipeline. Every inbound
9
+ # message destined for the LLM user input, tool responses,
10
+ # sub-agent replies, Mneme recalls, Melete skills/goals lands here
11
+ # first, then gets promoted into a real {Message} by {DrainJob}.
12
+ #
13
+ # Each pending message knows its source (+source_type+, +source_name+)
14
+ # and how to serialize itself for the LLM conversation via {#to_llm_messages}.
15
+ # Non-user messages (sub-agent results, recalled skills, workflows, recall,
16
+ # goal events) become synthetic tool_use/tool_result pairs so the LLM sees
17
+ # "a tool I invoked returned a result" rather than "a user wrote me."
18
+ #
19
+ # Classifies itself for the pipeline via +kind+ (+active+ triggers the
20
+ # drain loop, +background+ enriches context silently) and +message_type+
21
+ # (selects which pipeline event to emit on create).
12
22
  #
13
23
  # @see Session#enqueue_user_message
14
- # @see Session#promote_pending_messages!
24
+ # @see DrainJob — promotes PMs into Messages
25
+ # @see Events::StartMelete
26
+ # @see Events::StartProcessing
15
27
  class PendingMessage < ApplicationRecord
28
+ # Phantom tool names follow the `from_<sender>` convention: the prefix
29
+ # tells the LLM these are messages delivered to it by its sisters or
30
+ # sub-agents, not tools it invoked. Melete's contributions carry the
31
+ # type in the suffix so the viewport query can filter by kind.
32
+ MELETE_SKILL_TOOL = "from_melete_skill"
33
+ MELETE_WORKFLOW_TOOL = "from_melete_workflow"
34
+ MELETE_GOAL_TOOL = "from_melete_goal"
35
+ MNEME_TOOL = "from_mneme"
36
+
37
+ # Source types that produce phantom tool_use/tool_result pairs on promotion.
38
+ # User messages produce plain text blocks instead.
39
+ PHANTOM_PAIR_TYPES = %w[subagent skill workflow recall goal].freeze
40
+
41
+ # Maps each phantom pair source type to a lambda that builds its
42
+ # synthetic tool name. Each Melete contribution carries the type in
43
+ # its suffix; recalled memories come from Mneme; sub-agents encode
44
+ # their nickname directly (e.g. `from_sleuth`).
45
+ PHANTOM_TOOL_NAMES = {
46
+ "subagent" => ->(name) { "from_#{name}" },
47
+ "skill" => ->(_) { MELETE_SKILL_TOOL },
48
+ "workflow" => ->(_) { MELETE_WORKFLOW_TOOL },
49
+ "recall" => ->(_) { MNEME_TOOL },
50
+ "goal" => ->(_) { MELETE_GOAL_TOOL }
51
+ }.freeze
52
+
53
+ # Maps each phantom pair source type to a lambda building its tool input.
54
+ PHANTOM_TOOL_INPUTS = {
55
+ "subagent" => ->(name) { {from: name} },
56
+ "skill" => ->(name) { {skill: name} },
57
+ "workflow" => ->(name) { {workflow: name} },
58
+ "recall" => ->(name) { {message_id: name.to_i} },
59
+ "goal" => ->(name) { {goal_id: name.to_i} }
60
+ }.freeze
61
+
62
+ # Every message_type has a defined drain-pipeline role. +active+ types
63
+ # trigger the drain loop when the session is idle; +background+ types
64
+ # enrich context silently and ride the next active turn into the LLM.
65
+ # {#kind} is derived from this map in {#derive_kind} — callers only
66
+ # supply +message_type+.
67
+ MESSAGE_TYPE_KINDS = {
68
+ "user_message" => "active",
69
+ "tool_response" => "active",
70
+ "subagent" => "active",
71
+ "from_mneme" => "background",
72
+ "from_melete_skill" => "background",
73
+ "from_melete_workflow" => "background",
74
+ "from_melete_goal" => "background"
75
+ }.freeze
76
+
77
+ MESSAGE_TYPES = MESSAGE_TYPE_KINDS.keys.freeze
78
+
79
+ # Routes active message types to the event that begins the drain pipeline.
80
+ # User messages enter through Melete (skill/workflow/goal preparation);
81
+ # Mneme then runs conditionally only when Melete actually mutates goals
82
+ # (set_goal / update_goal), so recall always fires against fresh goals.
83
+ # Tool responses and sub-agent deliveries bypass enrichment and go
84
+ # straight to the drain loop. Background message types route to nothing
85
+ # — they wait in the mailbox until an active turn drains them.
86
+ MESSAGE_TYPE_ROUTES = {
87
+ "user_message" => Events::StartMelete,
88
+ "tool_response" => Events::StartProcessing,
89
+ "subagent" => Events::StartProcessing
90
+ }.freeze
91
+
16
92
  belongs_to :session
17
93
 
94
+ # In-memory id of the +Message+ this PM becomes on {#promote!}. Not
95
+ # persisted — the PM row is destroyed as part of the promotion transaction.
96
+ # Used by {Session#release_with_bounce_back} to destroy the exact message
97
+ # that should bounce, instead of guessing from +messages.last+ (which could
98
+ # race under parallel drains).
99
+ attr_accessor :promoted_message_id
100
+
101
+ enum :kind, {background: "background", active: "active"}
102
+
103
+ before_validation :derive_kind, if: :message_type
104
+
18
105
  validates :content, presence: true
106
+ validates :source_name, presence: true, unless: :user?
107
+ validates :message_type, presence: true, inclusion: {in: MESSAGE_TYPES}
108
+ validates :tool_use_id, presence: true, if: -> { message_type == "tool_response" }
109
+
110
+ # Tool responses take priority over other active messages: they complete
111
+ # a tool round the LLM is waiting on, so promoting them first preserves
112
+ # the tool_use/tool_result pairing in the conversation. Other actives
113
+ # (user messages, sub-agent replies) wait their FIFO turn behind the
114
+ # completion.
115
+ scope :ordered_for_drain, -> {
116
+ active.order(Arel.sql("message_type = 'tool_response' DESC")).order(:created_at)
117
+ }
19
118
 
20
119
  after_create_commit :broadcast_created
120
+ after_create_commit :route_to_event_bus
21
121
  after_destroy_commit :broadcast_removed
22
122
 
123
+ # @return [Boolean] true when this is a plain user message
124
+ def user?
125
+ source_type == "user"
126
+ end
127
+
128
+ # @return [Boolean] true when this message originated from a sub-agent
129
+ def subagent?
130
+ source_type == "subagent"
131
+ end
132
+
133
+ # @return [Boolean] true when this message carries recalled skill content
134
+ def skill?
135
+ source_type == "skill"
136
+ end
137
+
138
+ # @return [Boolean] true when this message carries recalled workflow content
139
+ def workflow?
140
+ source_type == "workflow"
141
+ end
142
+
143
+ # @return [Boolean] true when this message is an associative recall phantom pair
144
+ def recall?
145
+ source_type == "recall"
146
+ end
147
+
148
+ # @return [Boolean] true when this message carries a goal event
149
+ def goal?
150
+ source_type == "goal"
151
+ end
152
+
153
+ # @return [Boolean] true when promotion produces phantom tool_use/tool_result pairs
154
+ def phantom_pair?
155
+ source_type.in?(PHANTOM_PAIR_TYPES)
156
+ end
157
+
158
+ # Draper hook: picks the concrete decorator subclass based on
159
+ # {#message_type}. Mirrors {Message#decorator_class} so each PM type
160
+ # renders with the same visual treatment as its promoted counterpart,
161
+ # marked dimmed via +status: "pending"+.
162
+ #
163
+ # PMs are the universal intake queue — every new message_type added
164
+ # under #427 lands here first. Raises on unmapped types so a missing
165
+ # decorator surfaces immediately as a hard failure instead of a
166
+ # silent nil that breaks downstream rendering.
167
+ #
168
+ # @return [Class] a {PendingMessageDecorator} subclass
169
+ # @raise [ArgumentError] if no decorator is registered for the message_type
170
+ def decorator_class
171
+ case message_type
172
+ when "user_message" then PendingUserMessageDecorator
173
+ when "tool_response" then PendingToolResponseDecorator
174
+ when "subagent" then PendingSubagentDecorator
175
+ when "from_mneme" then PendingFromMnemeDecorator
176
+ when "from_melete_skill" then PendingFromMeleteSkillDecorator
177
+ when "from_melete_workflow" then PendingFromMeleteWorkflowDecorator
178
+ when "from_melete_goal" then PendingFromMeleteGoalDecorator
179
+ else raise ArgumentError, "No decorator for PendingMessage message_type: #{message_type.inspect}"
180
+ end
181
+ end
182
+
183
+ # Promotes this PendingMessage into the session's conversation history.
184
+ # Dispatches on +message_type+: tool responses become +tool_response+
185
+ # Messages, user messages become +user_message+ Messages, phantom pair
186
+ # types (sub-agent, skill, workflow, recall, goal) become synthetic
187
+ # tool_use/tool_result pairs. The PM row is destroyed in the same
188
+ # transaction so partial promotion can never leave a stray mailbox entry.
189
+ #
190
+ # For promotions that yield a single Message, {#promoted_message_id}
191
+ # captures the new record's id — callers can then act on that specific
192
+ # message (e.g. {Session#release_with_bounce_back}) without guessing.
193
+ #
194
+ # @return [void]
195
+ def promote!
196
+ session.transaction do
197
+ if message_type == "tool_response"
198
+ self.promoted_message_id = promote_as_tool_response!.id
199
+ elsif message_type == "user_message"
200
+ self.promoted_message_id = session.create_user_message(
201
+ display_content,
202
+ source_type: source_type,
203
+ source_name: source_name
204
+ ).id
205
+ elsif phantom_pair?
206
+ promote_as_phantom_pair!
207
+ else
208
+ raise "PendingMessage ##{id} cannot promote: message_type=#{message_type.inspect}"
209
+ end
210
+ destroy!
211
+ end
212
+ end
213
+
214
+ # Phantom tool name for DB persistence and LLM injection.
215
+ # Each phantom pair source type maps to a synthetic tool name via
216
+ # {PHANTOM_TOOL_NAMES} — a lambda so sub-agent names can flow through.
217
+ #
218
+ # @return [String] phantom tool name
219
+ def phantom_tool_name
220
+ PHANTOM_TOOL_NAMES.fetch(source_type).call(source_name)
221
+ end
222
+
223
+ # Phantom tool input hash for DB persistence and LLM injection.
224
+ #
225
+ # @return [Hash] tool input hash
226
+ def phantom_tool_input
227
+ PHANTOM_TOOL_INPUTS.fetch(source_type).call(source_name)
228
+ end
229
+
230
+ # Content formatted for display and history persistence.
231
+ # Sub-agent messages include an attribution prefix. Skill/workflow
232
+ # messages include a recall label. User messages pass through unchanged.
233
+ #
234
+ # @return [String]
235
+ def display_content
236
+ case source_type
237
+ when "subagent"
238
+ format(Tools::ResponseTruncator::ATTRIBUTION_FORMAT, source_name, content)
239
+ when "skill"
240
+ "[recalled skill: #{source_name}]\n#{content}"
241
+ when "workflow"
242
+ "[recalled workflow: #{source_name}]\n#{content}"
243
+ when "goal"
244
+ "[goal #{source_name}]\n#{content}"
245
+ else
246
+ content
247
+ end
248
+ end
249
+
250
+ # Builds LLM message hashes for this pending message.
251
+ #
252
+ # Phantom pair types become synthetic tool_use/tool_result pairs so the
253
+ # LLM sees them as its own past invocations. User messages return plain
254
+ # content for injection as text blocks within the current tool_results turn.
255
+ #
256
+ # @return [Array<Hash>] synthetic tool pair for phantom pair types
257
+ # @return [String] raw content for user messages
258
+ def to_llm_messages
259
+ return content unless phantom_pair?
260
+
261
+ build_phantom_pair(phantom_tool_name, phantom_tool_input)
262
+ end
263
+
264
+ # Emits the event that kicks off the drain pipeline for active messages
265
+ # whenever the session is currently claimable. Claimability is delegated
266
+ # to the AASM via +may_start_processing?+ — true from +:idle+ always,
267
+ # true from +:executing+ only once +tool_round_complete?+ holds. This
268
+ # lets a tool_response PM landing mid-round wake the drain only when
269
+ # its sibling responses are all present.
270
+ #
271
+ # Background messages never trigger; active messages landing while the
272
+ # session is unclaimable queue silently —
273
+ # {Session#wake_drain_pipeline_if_pending} re-invokes this on the next
274
+ # transition into +:idle+.
275
+ #
276
+ # Also fires from +after_create_commit+ so freshly enqueued PMs route
277
+ # themselves on persistence.
278
+ def route_to_event_bus
279
+ return unless active?
280
+ return unless session.may_start_processing?
281
+
282
+ event_class = MESSAGE_TYPE_ROUTES.fetch(message_type)
283
+ Events::Bus.emit(event_class.new(session_id: session_id, pending_message_id: id))
284
+ end
285
+
286
+ # Builds the structured +pending_message_created+ payload for transmit/
287
+ # broadcast paths. Wraps the per-mode decorator output in the +rendered+
288
+ # key so the TUI's existing +extract_rendered+ pipeline applies.
289
+ #
290
+ # Required arg — callers always know the session view_mode. A default
291
+ # of +session.view_mode+ would trigger a SELECT per +after_create_commit+
292
+ # when the association isn't preloaded.
293
+ #
294
+ # The raw +content+ field is intentionally absent: decorators decide
295
+ # what crosses the wire per view_mode (e.g. background PMs return nil
296
+ # in basic so the user doesn't see internal pipeline noise). Sending
297
+ # raw content alongside +rendered+ would undercut that boundary.
298
+ #
299
+ # @param mode [String] view mode for decoration
300
+ # @return [Hash] payload ready for ActionCable transmission
301
+ def broadcast_payload(mode)
302
+ {
303
+ "action" => "pending_message_created",
304
+ "pending_message_id" => id,
305
+ "message_type" => message_type,
306
+ "rendered" => {mode => decorate.render(mode)}
307
+ }
308
+ end
309
+
23
310
  private
24
311
 
312
+ # Persists a +tool_response+ Message for this PM and returns it.
313
+ # Mirrors the tool_use_id / payload shape emitted by
314
+ # {Events::Subscribers::LLMResponseHandler} for the paired +tool_call+.
315
+ #
316
+ # @return [Message]
317
+ def promote_as_tool_response!
318
+ session.messages.create!(
319
+ message_type: "tool_response",
320
+ tool_use_id: tool_use_id,
321
+ payload: {
322
+ "tool_name" => source_name,
323
+ "tool_use_id" => tool_use_id,
324
+ "content" => content,
325
+ "success" => success
326
+ },
327
+ timestamp: Time.current.to_ns,
328
+ token_count: TokenEstimation.estimate_token_count(content)
329
+ )
330
+ end
331
+
332
+ # Persists a synthetic +tool_call+/+tool_response+ Message pair so the
333
+ # LLM sees this contribution (sub-agent delivery, Melete skill/workflow/
334
+ # goal, Mneme recall) as a past tool invocation of its own. Keeps the
335
+ # system prompt stable for prompt caching — phantom pairs flow through
336
+ # the sliding-window conversation instead.
337
+ #
338
+ # @return [void]
339
+ def promote_as_phantom_pair!
340
+ tool_name = phantom_tool_name
341
+ uid = "#{tool_name}_#{id}"
342
+ now = Time.current.to_ns
343
+
344
+ session.messages.create!(
345
+ message_type: "tool_call",
346
+ tool_use_id: uid,
347
+ payload: {
348
+ "tool_name" => tool_name,
349
+ "tool_use_id" => uid,
350
+ "tool_input" => phantom_tool_input.stringify_keys,
351
+ "content" => display_content.lines.first.chomp
352
+ },
353
+ timestamp: now,
354
+ token_count: Mneme::TOOL_PAIR_OVERHEAD_TOKENS
355
+ )
356
+
357
+ session.messages.create!(
358
+ message_type: "tool_response",
359
+ tool_use_id: uid,
360
+ payload: {
361
+ "tool_name" => tool_name,
362
+ "tool_use_id" => uid,
363
+ "content" => content,
364
+ "success" => true
365
+ },
366
+ timestamp: now,
367
+ token_count: TokenEstimation.estimate_token_count(content)
368
+ )
369
+
370
+ restore_subagent_hud_visibility! if subagent?
371
+ end
372
+
373
+ # Flips the delivering sub-agent back to +hud_visible: true+ when the
374
+ # phantom pair we just persisted reintroduces a trace. A subsequent
375
+ # eviction that passes every trace will hide the sub-agent again via
376
+ # {Mneme::Runner#refresh_subagent_visibility}.
377
+ #
378
+ # Re-broadcasts the parent's children list on flip so the TUI adds the
379
+ # HUD entry back without waiting for the next AASM state change.
380
+ def restore_subagent_hud_visibility!
381
+ child = session.child_sessions.find_by(name: source_name)
382
+ return unless child && !child.hud_visible
383
+
384
+ child.update_column(:hud_visible, true)
385
+ child.broadcast_children_update_to_parent
386
+ end
387
+
388
+ # Builds a phantom tool_use/tool_result message pair.
389
+ # Follows the same format for all non-user source types — the only
390
+ # difference is the tool name and input hash.
391
+ #
392
+ # Phantom pairs keep the system prompt stable for prompt caching (#395).
393
+ # Instead of injecting skills/workflows into the system prompt (which
394
+ # busts the cache on every change), they flow through the sliding window
395
+ # as messages the LLM "recalls" via phantom tool invocations.
396
+ #
397
+ # @param tool_name [String] phantom tool name (not in the agent's registry)
398
+ # @param input [Hash] tool input hash
399
+ # @return [Array<Hash>] two-element array: assistant tool_use + user tool_result
400
+ def build_phantom_pair(tool_name, input)
401
+ tool_use_id = "#{tool_name}_#{id}"
402
+ [
403
+ {role: "assistant", content: [
404
+ {type: "tool_use", id: tool_use_id, name: tool_name, input: input}
405
+ ]},
406
+ {role: "user", content: [
407
+ {type: "tool_result", tool_use_id: tool_use_id, content: content}
408
+ ]}
409
+ ]
410
+ end
411
+
25
412
  # Broadcasts a pending message appearance so TUI clients render the
26
- # dimmed indicator immediately.
413
+ # type-specific dimmed indicator immediately. Includes the decorated
414
+ # payload for the session's current view mode so the TUI can dispatch
415
+ # by message type without a second round-trip.
27
416
  def broadcast_created
28
- ActionCable.server.broadcast("session_#{session_id}", {
29
- "action" => "pending_message_created",
30
- "pending_message_id" => id,
31
- "content" => content
32
- })
417
+ ActionCable.server.broadcast("session_#{session_id}", broadcast_payload(session.view_mode))
33
418
  end
34
419
 
35
420
  # Broadcasts pending message removal so TUI clients clear the entry.
@@ -40,4 +425,14 @@ class PendingMessage < ApplicationRecord
40
425
  "pending_message_id" => id
41
426
  })
42
427
  end
428
+
429
+ # Populates +kind+ from {MESSAGE_TYPE_KINDS} so callers only need to
430
+ # supply +message_type+. The mapping is the single source of truth for
431
+ # whether a message type triggers the drain loop or rides along as
432
+ # enrichment. Guarded by +if: :message_type+ on the callback — rows
433
+ # without a +message_type+ fail validation explicitly rather than
434
+ # crashing here.
435
+ def derive_kind
436
+ self.kind = MESSAGE_TYPE_KINDS.fetch(message_type)
437
+ end
43
438
  end
@@ -10,7 +10,12 @@
10
10
  #
11
11
  # @!attribute display_text
12
12
  # @return [String] truncated message content (~200 chars) shown in the Goals section
13
+ # @!attribute token_count
14
+ # @return [Integer] token count of {#display_text}. Seeded with a local
15
+ # estimate on create and later refined by {CountTokensJob}.
13
16
  class PinnedMessage < ApplicationRecord
17
+ include TokenEstimation
18
+
14
19
  # Display text limit — enough to recognize content, cheap on tokens.
15
20
  MAX_DISPLAY_TEXT_LENGTH = 200
16
21
 
@@ -34,8 +39,8 @@ class PinnedMessage < ApplicationRecord
34
39
  )
35
40
  }
36
41
 
37
- # @return [Integer] token cost estimate for viewport budget accounting
38
- def token_cost
39
- [(display_text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
42
+ # @return [String] truncated display text used for token estimation and counting
43
+ def tokenization_text
44
+ display_text.to_s
40
45
  end
41
46
  end