anima-core 1.4.0 → 1.5.1

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 (151) 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 +474 -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/aoide/phantom_call_filter.rb +49 -0
  56. data/lib/{analytical_brain.rb → aoide.rb} +6 -3
  57. data/lib/events/authentication_required.rb +24 -0
  58. data/lib/events/bounce_back.rb +4 -4
  59. data/lib/events/eviction_completed.rb +28 -0
  60. data/lib/events/goal_created.rb +28 -0
  61. data/lib/events/goal_updated.rb +32 -0
  62. data/lib/events/llm_responded.rb +35 -0
  63. data/lib/events/message_created.rb +27 -0
  64. data/lib/events/message_updated.rb +25 -0
  65. data/lib/events/session_state_changed.rb +30 -0
  66. data/lib/events/skill_activated.rb +28 -0
  67. data/lib/events/start_melete.rb +36 -0
  68. data/lib/events/start_mneme.rb +33 -0
  69. data/lib/events/start_processing.rb +32 -0
  70. data/lib/events/subagent_evicted.rb +31 -0
  71. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  72. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  73. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  74. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  75. data/lib/events/subscribers/llm_response_handler.rb +145 -0
  76. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  77. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  78. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  79. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  80. data/lib/events/subscribers/persister.rb +6 -8
  81. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  82. data/lib/events/subscribers/subagent_message_router.rb +26 -29
  83. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  84. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  85. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  86. data/lib/events/tool_executed.rb +34 -0
  87. data/lib/events/workflow_activated.rb +27 -0
  88. data/lib/llm/client.rb +41 -201
  89. data/lib/mcp/client_manager.rb +41 -46
  90. data/lib/mcp/stdio_transport.rb +9 -5
  91. data/lib/{analytical_brain → melete}/runner.rb +63 -68
  92. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +1 -1
  93. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +2 -2
  94. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  95. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +3 -3
  96. data/lib/{analytical_brain → melete}/tools/goal_messaging.rb +4 -3
  97. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +2 -2
  98. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  99. data/lib/{analytical_brain → melete}/tools/set_goal.rb +1 -1
  100. data/lib/{analytical_brain → melete}/tools/update_goal.rb +4 -4
  101. data/lib/melete.rb +26 -0
  102. data/lib/mneme/base_runner.rb +121 -0
  103. data/lib/mneme/l2_runner.rb +14 -20
  104. data/lib/mneme/recall_runner.rb +132 -0
  105. data/lib/mneme/runner.rb +118 -171
  106. data/lib/mneme/search.rb +104 -62
  107. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  108. data/lib/mneme/tools/save_snapshot.rb +2 -10
  109. data/lib/mneme/tools/surface_memory.rb +89 -0
  110. data/lib/mneme.rb +11 -5
  111. data/lib/shell_session.rb +303 -612
  112. data/lib/skills/definition.rb +2 -2
  113. data/lib/skills/registry.rb +1 -1
  114. data/lib/tools/base.rb +16 -0
  115. data/lib/tools/bash.rb +25 -57
  116. data/lib/tools/edit.rb +2 -0
  117. data/lib/tools/read.rb +2 -0
  118. data/lib/tools/registry.rb +79 -3
  119. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  120. data/lib/tools/spawn_specialist.rb +20 -10
  121. data/lib/tools/spawn_subagent.rb +24 -14
  122. data/lib/tools/subagent_prompts.rb +15 -4
  123. data/lib/tools/think.rb +1 -1
  124. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  125. data/lib/tools/write.rb +2 -0
  126. data/lib/tui/app.rb +5 -4
  127. data/lib/tui/braille_spinner.rb +7 -7
  128. data/lib/tui/decorators/base_decorator.rb +24 -3
  129. data/lib/tui/message_store.rb +93 -44
  130. data/lib/tui/screens/chat.rb +94 -20
  131. data/lib/tui/settings.rb +9 -2
  132. data/lib/workflows/definition.rb +3 -3
  133. data/lib/workflows/registry.rb +1 -1
  134. data/skills/github.md +38 -0
  135. data/templates/config.toml +4 -23
  136. data/workflows/review_pr.md +18 -14
  137. metadata +88 -28
  138. data/app/jobs/agent_request_job.rb +0 -199
  139. data/app/jobs/analytical_brain_job.rb +0 -33
  140. data/app/jobs/count_message_tokens_job.rb +0 -39
  141. data/app/jobs/passive_recall_job.rb +0 -24
  142. data/app/models/concerns/message/broadcasting.rb +0 -86
  143. data/lib/agent_loop.rb +0 -215
  144. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -40
  145. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -35
  146. data/lib/events/agent_message.rb +0 -25
  147. data/lib/events/subscribers/message_collector.rb +0 -64
  148. data/lib/events/tool_call.rb +0 -31
  149. data/lib/events/tool_response.rb +0 -33
  150. data/lib/mneme/compressed_viewport.rb +0 -204
  151. data/lib/mneme/passive_recall.rb +0 -138
data/app/models/goal.rb CHANGED
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # A persistent objective tracked by the analytical brain during a session.
3
+ # A persistent objective tracked by Melete during a session.
4
4
  # Goals form a two-level hierarchy: root goals represent high-level
5
5
  # objectives (semantic episodes), while sub-goals are TODO-style steps
6
6
  # rendered as checklist items in the agent's system prompt.
7
7
  #
8
- # The analytical brain creates and completes goals; the main agent sees
8
+ # Melete creates and completes goals; the main agent sees
9
9
  # them in its context window but never manages them directly.
10
10
  class Goal < ApplicationRecord
11
11
  STATUSES = %w[active completed].freeze
@@ -26,7 +26,7 @@ class Goal < ApplicationRecord
26
26
  scope :root, -> { where(parent_goal_id: nil) }
27
27
 
28
28
  # @!method self.not_evicted
29
- # Goals still visible in context (not yet evicted by the analytical brain).
29
+ # Goals still visible in context (not yet evicted by Melete).
30
30
  # @return [ActiveRecord::Relation]
31
31
  scope :not_evicted, -> { where(evicted_at: nil) }
32
32
 
@@ -37,7 +37,8 @@ class Goal < ApplicationRecord
37
37
  scope :evictable, -> { completed.where(evicted_at: nil) }
38
38
 
39
39
  after_commit :broadcast_goals_update
40
- after_commit :schedule_passive_recall, on: [:create, :update]
40
+ after_commit :emit_goal_created, on: :create
41
+ after_commit :emit_goal_updated, on: :update, if: :saved_change_to_description?
41
42
 
42
43
  # @return [Boolean] true if this goal has been completed
43
44
  def completed? = status == "completed"
@@ -56,7 +57,7 @@ class Goal < ApplicationRecord
56
57
  # the semantic episode that spawned them has ended.
57
58
  #
58
59
  # Uses +update_all+ to avoid N per-record +after_commit+ broadcasts;
59
- # the caller ({AnalyticalBrain::Tools::FinishGoal}) wraps the whole
60
+ # the caller ({Melete::Tools::FinishGoal}) wraps the whole
60
61
  # operation in a transaction so the root goal's single broadcast
61
62
  # includes the cascaded state.
62
63
  #
@@ -107,14 +108,24 @@ class Goal < ApplicationRecord
107
108
  errors.add(:parent_goal, "cannot nest deeper than two levels")
108
109
  end
109
110
 
110
- # Triggers passive recall when goals change so relevant memories
111
- # surface in the viewport automatically.
111
+ # Announces a freshly created goal so the active drain pipeline can
112
+ # decide whether Mneme should recall against the updated goal set.
113
+ # Only {MeleteEnrichmentJob}'s scoped listener observes these events;
114
+ # outside of a Melete run they are silently dropped.
112
115
  #
113
116
  # @return [void]
114
- def schedule_passive_recall
115
- return if session.sub_agent?
117
+ def emit_goal_created
118
+ Events::Bus.emit(Events::GoalCreated.new(session_id: session_id, goal_id: id))
119
+ end
116
120
 
117
- PassiveRecallJob.perform_later(session_id)
121
+ # Announces a description-level change to an existing goal. Status-only
122
+ # updates (finish, cascade, mark_goal_completed) are filtered out by the
123
+ # +saved_change_to_description?+ guard on the callback — a completed goal
124
+ # carries no new search seed for Mneme.
125
+ #
126
+ # @return [void]
127
+ def emit_goal_updated
128
+ Events::Bus.emit(Events::GoalUpdated.new(session_id: session_id, goal_id: id))
118
129
  end
119
130
 
120
131
  # Broadcasts goal changes to all clients subscribed to this session.
@@ -7,6 +7,10 @@
7
7
  # Not to be confused with {Events::Base} (transient bus signals).
8
8
  # Messages persist to SQLite; events flow through the bus and are gone.
9
9
  #
10
+ # After commit, emits {Events::MessageCreated} and {Events::MessageUpdated}
11
+ # lifecycle events so subscribers ({Events::Subscribers::MessageBroadcaster},
12
+ # {Events::Subscribers::MnemeScheduler}) can react without coupling.
13
+ #
10
14
  # @!attribute message_type
11
15
  # @return [String] one of {TYPES}: system_message, user_message,
12
16
  # agent_message, tool_call, tool_response
@@ -15,17 +19,18 @@
15
19
  # @!attribute timestamp
16
20
  # @return [Integer] nanoseconds since epoch (Process::CLOCK_REALTIME)
17
21
  # @!attribute token_count
18
- # @return [Integer] cached token count for this message's payload (0 until counted)
22
+ # @return [Integer] token count for this message's payload. Seeded with
23
+ # a local estimate on create and later refined by {CountTokensJob} using
24
+ # the real Anthropic tokenizer. Always positive — never zero or nil.
19
25
  # @!attribute tool_use_id
20
26
  # @return [String] ID correlating tool_call and tool_response messages
21
27
  # (Anthropic-assigned, or a SecureRandom.uuid fallback when the API returns nil;
22
28
  # required for tool_call and tool_response messages)
23
29
  class Message < ApplicationRecord
24
- include Message::Broadcasting
30
+ include TokenEstimation
25
31
 
26
32
  TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
27
33
  LLM_TYPES = %w[user_message agent_message].freeze
28
- CONTEXT_TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
29
34
  CONVERSATION_TYPES = %w[user_message agent_message system_message].freeze
30
35
  THINK_TOOL = "think"
31
36
  # Message types that require a tool_use_id to pair call with response.
@@ -33,21 +38,11 @@ class Message < ApplicationRecord
33
38
 
34
39
  ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
35
40
 
36
- # Heuristic: average bytes per token for English prose.
37
- BYTES_PER_TOKEN = 4
38
-
39
41
  # Synthetic ID for system prompt entries in the TUI message store.
40
42
  # Real message IDs are positive integers from the database, so 0
41
43
  # is safe for deduplication without collision risk.
42
44
  SYSTEM_PROMPT_ID = 0
43
45
 
44
- # Estimates token count from a byte size using the {BYTES_PER_TOKEN} heuristic.
45
- # @param bytesize [Integer] number of bytes
46
- # @return [Integer] estimated token count (at least 1)
47
- def self.estimate_token_count(bytesize)
48
- [(bytesize / BYTES_PER_TOKEN.to_f).ceil, 1].max
49
- end
50
-
51
46
  belongs_to :session
52
47
  has_many :pinned_messages, dependent: :destroy
53
48
 
@@ -57,17 +52,23 @@ class Message < ApplicationRecord
57
52
  # Anthropic requires every tool_use to have a matching tool_result with the same ID
58
53
  validates :tool_use_id, presence: true, if: -> { message_type.in?(TOOL_TYPES) }
59
54
 
60
- after_create :schedule_token_count, if: :llm_message?
55
+ after_create_commit :emit_created_event
56
+ after_update_commit :emit_updated_event
61
57
 
62
58
  # @!method self.llm_messages
63
59
  # Messages that represent conversation turns sent to the LLM API.
64
60
  # @return [ActiveRecord::Relation]
65
61
  scope :llm_messages, -> { where(message_type: LLM_TYPES) }
66
62
 
67
- # @!method self.context_messages
68
- # Messages included in the LLM context window (conversation + tool interactions).
63
+ # @!method self.conversation_or_think
64
+ # Conversation messages (user/agent/system) and think tool_calls
65
+ # the messages Mneme treats as boundary-eligible.
69
66
  # @return [ActiveRecord::Relation]
70
- scope :context_messages, -> { where(message_type: CONTEXT_TYPES) }
67
+ scope :conversation_or_think, -> {
68
+ where(message_type: CONVERSATION_TYPES)
69
+ .or(where(message_type: "tool_call")
70
+ .where("json_extract(payload, '$.tool_name') = ?", THINK_TOOL))
71
+ }
71
72
 
72
73
  # Maps message_type to the Anthropic Messages API role.
73
74
  # @return [String] "user" or "assistant"
@@ -75,16 +76,6 @@ class Message < ApplicationRecord
75
76
  ROLE_MAP.fetch(message_type)
76
77
  end
77
78
 
78
- # @return [Boolean] true if this message represents an LLM conversation turn
79
- def llm_message?
80
- message_type.in?(LLM_TYPES)
81
- end
82
-
83
- # @return [Boolean] true if this message is part of the LLM context window
84
- def context_message?
85
- message_type.in?(CONTEXT_TYPES)
86
- end
87
-
88
79
  # @return [Boolean] true if this is a conversation message (user/agent/system)
89
80
  # or a think tool_call — the messages Mneme treats as "conversation" for boundary tracking
90
81
  def conversation_or_think?
@@ -92,23 +83,43 @@ class Message < ApplicationRecord
92
83
  (message_type == "tool_call" && payload["tool_name"] == THINK_TOOL)
93
84
  end
94
85
 
95
- # Heuristic token estimate: ~4 bytes per token for English prose.
96
- # Tool messages are estimated from the full payload JSON since tool_input
97
- # and tool metadata contribute to token count. Messages use content only.
86
+ # String fed to the token estimator and the remote tokenizer. Tool
87
+ # messages serialize the full payload as JSON so +tool_name+, +tool_input+,
88
+ # and +tool_use_id+ contribute to the count; conversation messages use
89
+ # the content field only.
98
90
  #
99
- # @return [Integer] estimated token count (at least 1)
100
- def estimate_tokens
101
- text = if message_type.in?(TOOL_TYPES)
91
+ # @return [String]
92
+ def tokenization_text
93
+ if message_type.in?(TOOL_TYPES)
102
94
  payload.to_json
103
95
  else
104
96
  payload["content"].to_s
105
97
  end
106
- self.class.estimate_token_count(text.bytesize)
98
+ end
99
+
100
+ # Draper hook: picks the concrete decorator subclass based on
101
+ # {#message_type}. Overrides {Draper::Decoratable#decorator_class},
102
+ # which would otherwise default to the abstract {MessageDecorator}
103
+ # base class. Called implicitly by +message.decorate+.
104
+ #
105
+ # @return [Class] a {MessageDecorator} subclass
106
+ def decorator_class
107
+ case message_type
108
+ when "user_message" then UserMessageDecorator
109
+ when "agent_message" then AgentMessageDecorator
110
+ when "system_message" then SystemMessageDecorator
111
+ when "tool_call" then ToolCallDecorator
112
+ when "tool_response" then ToolResponseDecorator
113
+ end
107
114
  end
108
115
 
109
116
  private
110
117
 
111
- def schedule_token_count
112
- CountMessageTokensJob.perform_later(id)
118
+ def emit_created_event
119
+ Events::Bus.emit(Events::MessageCreated.new(self))
120
+ end
121
+
122
+ def emit_updated_event
123
+ Events::Bus.emit(Events::MessageUpdated.new(self))
113
124
  end
114
125
  end
@@ -5,10 +5,10 @@
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 message arrives 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
12
  #
13
13
  # Each pending message knows its source (+source_type+, +source_name+)
14
14
  # and how to serialize itself for the LLM conversation via {#to_llm_messages}.
@@ -16,30 +16,38 @@
16
16
  # goal events) become synthetic tool_use/tool_result pairs so the LLM sees
17
17
  # "a tool I invoked returned a result" rather than "a user wrote me."
18
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).
22
+ #
19
23
  # @see Session#enqueue_user_message
20
- # @see Session#promote_pending_messages!
24
+ # @see DrainJob — promotes PMs into Messages
25
+ # @see Events::StartMelete
26
+ # @see Events::StartProcessing
21
27
  class PendingMessage < ApplicationRecord
22
- # Synthetic tool names used in tool_use/tool_result pairs injected into
23
- # the parent LLM conversation when non-user messages are promoted.
24
- # These tools don't exist in the agent's registry the agent sees
25
- # them as its own past actions (phantom tool calls).
26
- SUBAGENT_TOOL = "subagent_message"
27
- RECALL_SKILL_TOOL = "recall_skill"
28
- RECALL_WORKFLOW_TOOL = "recall_workflow"
29
- RECALL_MEMORY_TOOL = "recall_memory"
30
- RECALL_GOAL_TOOL = "recall_goal"
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"
31
36
 
32
37
  # Source types that produce phantom tool_use/tool_result pairs on promotion.
33
38
  # User messages produce plain text blocks instead.
34
39
  PHANTOM_PAIR_TYPES = %w[subagent skill workflow recall goal].freeze
35
40
 
36
- # Maps each phantom pair source type to its synthetic tool name.
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`).
37
45
  PHANTOM_TOOL_NAMES = {
38
- "subagent" => SUBAGENT_TOOL,
39
- "skill" => RECALL_SKILL_TOOL,
40
- "workflow" => RECALL_WORKFLOW_TOOL,
41
- "recall" => RECALL_MEMORY_TOOL,
42
- "goal" => RECALL_GOAL_TOOL
46
+ "subagent" => ->(name) { "from_#{name}" },
47
+ "skill" => ->(_) { MELETE_SKILL_TOOL },
48
+ "workflow" => ->(_) { MELETE_WORKFLOW_TOOL },
49
+ "recall" => ->(_) { MNEME_TOOL },
50
+ "goal" => ->(_) { MELETE_GOAL_TOOL }
43
51
  }.freeze
44
52
 
45
53
  # Maps each phantom pair source type to a lambda building its tool input.
@@ -51,13 +59,65 @@ class PendingMessage < ApplicationRecord
51
59
  "goal" => ->(name) { {goal_id: name.to_i} }
52
60
  }.freeze
53
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
+
54
92
  belongs_to :session
55
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
+
56
105
  validates :content, presence: true
57
- validates :source_type, inclusion: {in: %w[user subagent skill workflow recall goal]}
58
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
+ }
59
118
 
60
119
  after_create_commit :broadcast_created
120
+ after_create_commit :route_to_event_bus
61
121
  after_destroy_commit :broadcast_removed
62
122
 
63
123
  # @return [Boolean] true when this is a plain user message
@@ -95,12 +155,69 @@ class PendingMessage < ApplicationRecord
95
155
  source_type.in?(PHANTOM_PAIR_TYPES)
96
156
  end
97
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
+
98
214
  # Phantom tool name for DB persistence and LLM injection.
99
- # Each phantom pair source type maps to a synthetic tool name.
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.
100
217
  #
101
218
  # @return [String] phantom tool name
102
219
  def phantom_tool_name
103
- PHANTOM_TOOL_NAMES.fetch(source_type)
220
+ PHANTOM_TOOL_NAMES.fetch(source_type).call(source_name)
104
221
  end
105
222
 
106
223
  # Phantom tool input hash for DB persistence and LLM injection.
@@ -144,8 +261,130 @@ class PendingMessage < ApplicationRecord
144
261
  build_phantom_pair(phantom_tool_name, phantom_tool_input)
145
262
  end
146
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
+
147
310
  private
148
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
+
149
388
  # Builds a phantom tool_use/tool_result message pair.
150
389
  # Follows the same format for all non-user source types — the only
151
390
  # difference is the tool name and input hash.
@@ -171,13 +410,11 @@ class PendingMessage < ApplicationRecord
171
410
  end
172
411
 
173
412
  # Broadcasts a pending message appearance so TUI clients render the
174
- # 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.
175
416
  def broadcast_created
176
- ActionCable.server.broadcast("session_#{session_id}", {
177
- "action" => "pending_message_created",
178
- "pending_message_id" => id,
179
- "content" => content
180
- })
417
+ ActionCable.server.broadcast("session_#{session_id}", broadcast_payload(session.view_mode))
181
418
  end
182
419
 
183
420
  # Broadcasts pending message removal so TUI clients clear the entry.
@@ -188,4 +425,14 @@ class PendingMessage < ApplicationRecord
188
425
  "pending_message_id" => id
189
426
  })
190
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
191
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