anima-core 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +23 -26
  3. data/README.md +118 -104
  4. data/agents/thoughts-analyzer.md +12 -7
  5. data/anima-core.gemspec +1 -0
  6. data/app/channels/session_channel.rb +38 -58
  7. data/app/decorators/agent_message_decorator.rb +7 -2
  8. data/app/decorators/message_decorator.rb +31 -100
  9. data/app/decorators/pending_from_melete_decorator.rb +36 -0
  10. data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
  11. data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
  12. data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
  13. data/app/decorators/pending_from_mneme_decorator.rb +44 -0
  14. data/app/decorators/pending_message_decorator.rb +94 -0
  15. data/app/decorators/pending_subagent_decorator.rb +46 -0
  16. data/app/decorators/pending_tool_response_decorator.rb +51 -0
  17. data/app/decorators/pending_user_message_decorator.rb +22 -0
  18. data/app/decorators/system_message_decorator.rb +5 -0
  19. data/app/decorators/tool_call_decorator.rb +16 -5
  20. data/app/decorators/tool_response_decorator.rb +2 -2
  21. data/app/decorators/user_message_decorator.rb +7 -2
  22. data/app/jobs/count_tokens_job.rb +23 -0
  23. data/app/jobs/drain_job.rb +169 -0
  24. data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
  25. data/app/jobs/melete_enrichment_job.rb +48 -0
  26. data/app/jobs/mneme_enrichment_job.rb +46 -0
  27. data/app/jobs/tool_execution_job.rb +87 -0
  28. data/app/models/concerns/token_estimation.rb +54 -0
  29. data/app/models/goal.rb +23 -11
  30. data/app/models/message.rb +46 -48
  31. data/app/models/pending_message.rb +407 -12
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +660 -566
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +157 -0
  36. data/bin/release +212 -0
  37. data/bin/with-llms +20 -0
  38. data/config/application.rb +1 -0
  39. data/config/database.yml +1 -0
  40. data/config/initializers/event_subscribers.rb +71 -4
  41. data/config/initializers/inflections.rb +3 -1
  42. data/db/cable_structure.sql +9 -0
  43. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  44. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  45. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  46. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  47. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  48. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  49. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  50. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  51. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  52. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  53. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  54. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  55. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  56. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  57. data/db/queue_structure.sql +61 -0
  58. data/db/structure.sql +133 -0
  59. data/lib/agents/registry.rb +1 -1
  60. data/lib/anima/cli.rb +41 -13
  61. data/lib/anima/installer.rb +13 -0
  62. data/lib/anima/settings.rb +16 -36
  63. data/lib/anima/version.rb +1 -1
  64. data/lib/events/authentication_required.rb +24 -0
  65. data/lib/events/bounce_back.rb +4 -4
  66. data/lib/events/eviction_completed.rb +28 -0
  67. data/lib/events/goal_created.rb +28 -0
  68. data/lib/events/goal_updated.rb +32 -0
  69. data/lib/events/llm_responded.rb +35 -0
  70. data/lib/events/message_created.rb +27 -0
  71. data/lib/events/message_updated.rb +25 -0
  72. data/lib/events/session_state_changed.rb +30 -0
  73. data/lib/events/skill_activated.rb +28 -0
  74. data/lib/events/start_melete.rb +36 -0
  75. data/lib/events/start_mneme.rb +33 -0
  76. data/lib/events/start_processing.rb +32 -0
  77. data/lib/events/subagent_evicted.rb +31 -0
  78. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  79. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  80. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  81. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  82. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  83. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  84. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  85. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  86. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  87. data/lib/events/subscribers/persister.rb +8 -9
  88. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  89. data/lib/events/subscribers/subagent_message_router.rb +28 -34
  90. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  91. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  92. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  93. data/lib/events/tool_executed.rb +34 -0
  94. data/lib/events/workflow_activated.rb +27 -0
  95. data/lib/llm/client.rb +46 -199
  96. data/lib/mcp/client_manager.rb +41 -46
  97. data/lib/mcp/stdio_transport.rb +9 -5
  98. data/lib/{analytical_brain → melete}/runner.rb +73 -68
  99. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
  100. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
  101. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  102. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
  103. data/lib/melete/tools/goal_messaging.rb +29 -0
  104. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
  105. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  106. data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
  107. data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
  108. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  109. data/lib/mneme/base_runner.rb +121 -0
  110. data/lib/mneme/l2_runner.rb +14 -20
  111. data/lib/mneme/recall_runner.rb +132 -0
  112. data/lib/mneme/runner.rb +123 -165
  113. data/lib/mneme/search.rb +104 -62
  114. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  115. data/lib/mneme/tools/save_snapshot.rb +2 -10
  116. data/lib/mneme/tools/surface_memory.rb +89 -0
  117. data/lib/mneme.rb +11 -5
  118. data/lib/providers/anthropic.rb +112 -7
  119. data/lib/shell_session.rb +290 -432
  120. data/lib/skills/definition.rb +2 -2
  121. data/lib/skills/registry.rb +1 -1
  122. data/lib/tools/base.rb +16 -1
  123. data/lib/tools/bash.rb +25 -55
  124. data/lib/tools/edit.rb +2 -0
  125. data/lib/tools/mark_goal_completed.rb +4 -5
  126. data/lib/tools/read.rb +2 -0
  127. data/lib/tools/registry.rb +85 -4
  128. data/lib/tools/response_truncator.rb +1 -1
  129. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  130. data/lib/tools/spawn_specialist.rb +22 -14
  131. data/lib/tools/spawn_subagent.rb +30 -20
  132. data/lib/tools/subagent_prompts.rb +17 -19
  133. data/lib/tools/think.rb +1 -1
  134. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  135. data/lib/tools/write.rb +2 -0
  136. data/lib/tui/app.rb +393 -149
  137. data/lib/tui/braille_spinner.rb +7 -7
  138. data/lib/tui/cable_client.rb +9 -16
  139. data/lib/tui/decorators/base_decorator.rb +47 -6
  140. data/lib/tui/decorators/bash_decorator.rb +1 -1
  141. data/lib/tui/decorators/edit_decorator.rb +4 -2
  142. data/lib/tui/decorators/read_decorator.rb +4 -2
  143. data/lib/tui/decorators/think_decorator.rb +2 -2
  144. data/lib/tui/decorators/web_get_decorator.rb +1 -1
  145. data/lib/tui/decorators/write_decorator.rb +4 -2
  146. data/lib/tui/flash.rb +19 -14
  147. data/lib/tui/formatting.rb +20 -9
  148. data/lib/tui/input_buffer.rb +6 -6
  149. data/lib/tui/message_store.rb +165 -28
  150. data/lib/tui/performance_logger.rb +2 -3
  151. data/lib/tui/screens/chat.rb +149 -79
  152. data/lib/tui/settings.rb +93 -0
  153. data/lib/workflows/definition.rb +3 -3
  154. data/lib/workflows/registry.rb +1 -1
  155. data/skills/github.md +38 -0
  156. data/templates/config.toml +16 -32
  157. data/templates/tui.toml +209 -0
  158. data/workflows/review_pr.md +18 -14
  159. metadata +98 -29
  160. data/app/jobs/agent_request_job.rb +0 -199
  161. data/app/jobs/analytical_brain_job.rb +0 -33
  162. data/app/jobs/count_message_tokens_job.rb +0 -39
  163. data/app/jobs/passive_recall_job.rb +0 -29
  164. data/app/models/concerns/message/broadcasting.rb +0 -85
  165. data/config/initializers/fts5_schema_dump.rb +0 -21
  166. data/lib/agent_loop.rb +0 -186
  167. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
  168. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
  169. data/lib/environment_probe.rb +0 -232
  170. data/lib/events/agent_message.rb +0 -11
  171. data/lib/events/subscribers/message_collector.rb +0 -64
  172. data/lib/events/tool_call.rb +0 -31
  173. data/lib/events/tool_response.rb +0 -33
  174. data/lib/mneme/compressed_viewport.rb +0 -200
  175. data/lib/mneme/passive_recall.rb +0 -69
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Runs a single tool on behalf of the session and reports the outcome.
4
+ #
5
+ # Queued by {Events::Subscribers::LLMResponseHandler} when the LLM
6
+ # returns a +tool_use+ block. The session is already in the +:executing+
7
+ # state (transition owned by the response handler). This job:
8
+ #
9
+ # 1. Dispatches the tool via {Tools::Registry}.
10
+ # 2. Truncates and formats the result.
11
+ # 3. Emits {Events::ToolExecuted}.
12
+ #
13
+ # The job does not release the session or create the +tool_response+
14
+ # PendingMessage — that's {Events::Subscribers::ToolResponseCreator}'s
15
+ # job. Event emission is the final act that hands control off.
16
+ class ToolExecutionJob < ApplicationJob
17
+ queue_as :default
18
+
19
+ discard_on ActiveRecord::RecordNotFound
20
+
21
+ # @param session_id [Integer]
22
+ # @param tool_use_id [String] Anthropic-assigned pairing ID
23
+ # @param tool_name [String]
24
+ # @param tool_input [Hash]
25
+ def perform(session_id, tool_use_id:, tool_name:, tool_input:)
26
+ session = Session.find(session_id)
27
+ # ShellSession.for_session returns the conversation's persistent shell
28
+ # — spawned on first use, reused on every subsequent tool call so the
29
+ # agent's cd's and exported env vars survive between calls. We do NOT
30
+ # finalize it here; the shell's lifetime is the Session's lifetime.
31
+ shell_session = ShellSession.for_session(session)
32
+ registry = Tools::Registry.build(session: session, shell_session: shell_session)
33
+
34
+ content, success = execute(registry, tool_name, tool_input, tool_use_id)
35
+
36
+ Events::Bus.emit(Events::ToolExecuted.new(
37
+ session_id: session_id,
38
+ tool_use_id: tool_use_id,
39
+ tool_name: tool_name,
40
+ content: content,
41
+ success: success
42
+ ))
43
+ rescue => error
44
+ # A missing {Events::ToolExecuted} would leave the session in +:executing+
45
+ # forever. Always emit a synthetic failure event so
46
+ # {Events::Subscribers::ToolResponseCreator} runs and releases the claim.
47
+ Rails.logger.error("ToolExecutionJob crashed: #{error.class}: #{error.message}")
48
+ Events::Bus.emit(Events::ToolExecuted.new(
49
+ session_id: session_id,
50
+ tool_use_id: tool_use_id,
51
+ tool_name: tool_name,
52
+ content: "#{error.class}: #{error.message}",
53
+ success: false
54
+ ))
55
+ end
56
+
57
+ private
58
+
59
+ # Always emits something executable back — a missing +tool_result+
60
+ # permanently corrupts the Anthropic conversation history.
61
+ def execute(registry, tool_name, tool_input, tool_use_id)
62
+ result = registry.execute(tool_name, tool_input, tool_use_id: tool_use_id)
63
+ result = ::ToolDecorator.call(tool_name, result)
64
+ content = format_result(result)
65
+ content = truncate(content, registry, tool_name)
66
+ [content, !result.is_a?(Hash) || !result.key?(:error)]
67
+ rescue => error
68
+ Rails.logger.error("Tool #{tool_name} raised #{error.class}: #{error.message}")
69
+ [format_result(error: "#{error.class}: #{error.message}"), false]
70
+ end
71
+
72
+ def format_result(result)
73
+ result.is_a?(Hash) ? result.to_json : result.to_s
74
+ end
75
+
76
+ def truncate(content, registry, tool_name)
77
+ threshold = registry.truncation_threshold(tool_name)
78
+ return content unless threshold
79
+
80
+ lines = ::Tools::ResponseTruncator::HEAD_LINES
81
+ ::Tools::ResponseTruncator.truncate(
82
+ content,
83
+ threshold: threshold,
84
+ reason: "#{tool_name} output displays first/last #{lines} lines"
85
+ )
86
+ end
87
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shared token-count lifecycle for records that ride in the LLM context
4
+ # window. Including models seed {#token_count} with a local heuristic on
5
+ # create and schedule {CountTokensJob} to refine it with the real Anthropic
6
+ # tokenizer count.
7
+ #
8
+ # Non-AR callers (TUI debug display, phantom-pair sizing, byte-cap
9
+ # calculations) use {.estimate_token_count} and {BYTES_PER_TOKEN} as
10
+ # module-level helpers without including the concern.
11
+ #
12
+ # Including models must implement +#tokenization_text+ returning the
13
+ # string whose token count should be estimated and later refined.
14
+ module TokenEstimation
15
+ extend ActiveSupport::Concern
16
+
17
+ # Heuristic: average bytes per token for English prose.
18
+ BYTES_PER_TOKEN = 4
19
+
20
+ # Estimates token count from a string using the {BYTES_PER_TOKEN} heuristic.
21
+ #
22
+ # @param text [String, nil]
23
+ # @return [Integer] estimated token count (0 for blank input)
24
+ def self.estimate_token_count(text)
25
+ (text.to_s.bytesize / BYTES_PER_TOKEN.to_f).ceil
26
+ end
27
+
28
+ included do
29
+ before_validation :set_estimated_token_count, on: :create
30
+ after_create :schedule_token_count
31
+ end
32
+
33
+ # Heuristic token estimate for this record's {#tokenization_text}.
34
+ #
35
+ # @return [Integer]
36
+ def estimate_tokens
37
+ TokenEstimation.estimate_token_count(tokenization_text)
38
+ end
39
+
40
+ private
41
+
42
+ # Seeds {#token_count} with a local estimate before the record is saved.
43
+ # Respects an explicit positive value passed by the caller (e.g. tests
44
+ # that want deterministic counts).
45
+ def set_estimated_token_count
46
+ return if token_count.to_i.positive?
47
+
48
+ self.token_count = estimate_tokens
49
+ end
50
+
51
+ def schedule_token_count
52
+ CountTokensJob.perform_later(self)
53
+ end
54
+ end
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,17 +26,19 @@ 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
 
33
33
  # @!method self.evictable
34
- # Completed goals pending evictionvisible to the brain for age-based review.
34
+ # Completed goals not yet evicted their phantom pairs remain in the
35
+ # sliding window until Mneme compresses them during the eviction cycle.
35
36
  # @return [ActiveRecord::Relation]
36
37
  scope :evictable, -> { completed.where(evicted_at: nil) }
37
38
 
38
39
  after_commit :broadcast_goals_update
39
- 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?
40
42
 
41
43
  # @return [Boolean] true if this goal has been completed
42
44
  def completed? = status == "completed"
@@ -55,7 +57,7 @@ class Goal < ApplicationRecord
55
57
  # the semantic episode that spawned them has ended.
56
58
  #
57
59
  # Uses +update_all+ to avoid N per-record +after_commit+ broadcasts;
58
- # the caller ({AnalyticalBrain::Tools::FinishGoal}) wraps the whole
60
+ # the caller ({Melete::Tools::FinishGoal}) wraps the whole
59
61
  # operation in a transaction so the root goal's single broadcast
60
62
  # includes the cascaded state.
61
63
  #
@@ -106,14 +108,24 @@ class Goal < ApplicationRecord
106
108
  errors.add(:parent_goal, "cannot nest deeper than two levels")
107
109
  end
108
110
 
109
- # Triggers passive recall when goals change so relevant memories
110
- # 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.
111
115
  #
112
116
  # @return [void]
113
- def schedule_passive_recall
114
- 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
115
120
 
116
- 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))
117
129
  end
118
130
 
119
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,41 +19,30 @@
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
- SPAWN_TOOLS = %w[spawn_subagent spawn_specialist].freeze
32
-
33
36
  # Message types that require a tool_use_id to pair call with response.
34
37
  TOOL_TYPES = %w[tool_call tool_response].freeze
35
38
 
36
39
  ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
37
40
 
38
- # Heuristic: average bytes per token for English prose.
39
- BYTES_PER_TOKEN = 4
40
-
41
41
  # Synthetic ID for system prompt entries in the TUI message store.
42
42
  # Real message IDs are positive integers from the database, so 0
43
43
  # is safe for deduplication without collision risk.
44
44
  SYSTEM_PROMPT_ID = 0
45
45
 
46
- # Estimates token count from a byte size using the {BYTES_PER_TOKEN} heuristic.
47
- # @param bytesize [Integer] number of bytes
48
- # @return [Integer] estimated token count (at least 1)
49
- def self.estimate_token_count(bytesize)
50
- [(bytesize / BYTES_PER_TOKEN.to_f).ceil, 1].max
51
- end
52
-
53
46
  belongs_to :session
54
47
  has_many :pinned_messages, dependent: :destroy
55
48
 
@@ -59,27 +52,22 @@ class Message < ApplicationRecord
59
52
  # Anthropic requires every tool_use to have a matching tool_result with the same ID
60
53
  validates :tool_use_id, presence: true, if: -> { message_type.in?(TOOL_TYPES) }
61
54
 
62
- after_create :schedule_token_count, if: :llm_message?
55
+ after_create_commit :emit_created_event
56
+ after_update_commit :emit_updated_event
63
57
 
64
58
  # @!method self.llm_messages
65
59
  # Messages that represent conversation turns sent to the LLM API.
66
60
  # @return [ActiveRecord::Relation]
67
61
  scope :llm_messages, -> { where(message_type: LLM_TYPES) }
68
62
 
69
- # @!method self.context_messages
70
- # 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.
71
66
  # @return [ActiveRecord::Relation]
72
- scope :context_messages, -> { where(message_type: CONTEXT_TYPES) }
73
-
74
- # @!method self.excluding_spawn_messages
75
- # Excludes spawn_subagent/spawn_specialist tool_call and tool_response messages.
76
- # Used when building parent context for sub-agents — spawn messages cause role
77
- # confusion because the sub-agent sees sibling spawn results and mistakes
78
- # itself for the parent.
79
- # @return [ActiveRecord::Relation]
80
- scope :excluding_spawn_messages, -> {
81
- where.not("message_type IN (?) AND json_extract(payload, '$.tool_name') IN (?)",
82
- TOOL_TYPES, SPAWN_TOOLS)
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))
83
71
  }
84
72
 
85
73
  # Maps message_type to the Anthropic Messages API role.
@@ -88,16 +76,6 @@ class Message < ApplicationRecord
88
76
  ROLE_MAP.fetch(message_type)
89
77
  end
90
78
 
91
- # @return [Boolean] true if this message represents an LLM conversation turn
92
- def llm_message?
93
- message_type.in?(LLM_TYPES)
94
- end
95
-
96
- # @return [Boolean] true if this message is part of the LLM context window
97
- def context_message?
98
- message_type.in?(CONTEXT_TYPES)
99
- end
100
-
101
79
  # @return [Boolean] true if this is a conversation message (user/agent/system)
102
80
  # or a think tool_call — the messages Mneme treats as "conversation" for boundary tracking
103
81
  def conversation_or_think?
@@ -105,23 +83,43 @@ class Message < ApplicationRecord
105
83
  (message_type == "tool_call" && payload["tool_name"] == THINK_TOOL)
106
84
  end
107
85
 
108
- # Heuristic token estimate: ~4 bytes per token for English prose.
109
- # Tool messages are estimated from the full payload JSON since tool_input
110
- # 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.
111
90
  #
112
- # @return [Integer] estimated token count (at least 1)
113
- def estimate_tokens
114
- text = if message_type.in?(TOOL_TYPES)
91
+ # @return [String]
92
+ def tokenization_text
93
+ if message_type.in?(TOOL_TYPES)
115
94
  payload.to_json
116
95
  else
117
96
  payload["content"].to_s
118
97
  end
119
- 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
120
114
  end
121
115
 
122
116
  private
123
117
 
124
- def schedule_token_count
125
- 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))
126
124
  end
127
125
  end