anima-core 1.1.3 → 1.2.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +2 -0
  3. data/agents/codebase-analyzer.md +1 -1
  4. data/agents/codebase-pattern-finder.md +1 -1
  5. data/agents/documentation-researcher.md +1 -1
  6. data/agents/thoughts-analyzer.md +1 -1
  7. data/agents/web-search-researcher.md +1 -1
  8. data/app/channels/session_channel.rb +44 -43
  9. data/app/decorators/agent_message_decorator.rb +2 -2
  10. data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
  11. data/app/decorators/system_message_decorator.rb +2 -2
  12. data/app/decorators/tool_call_decorator.rb +2 -2
  13. data/app/decorators/tool_decorator.rb +4 -4
  14. data/app/decorators/tool_response_decorator.rb +2 -2
  15. data/app/decorators/user_message_decorator.rb +3 -3
  16. data/app/decorators/web_get_tool_decorator.rb +41 -9
  17. data/app/jobs/agent_request_job.rb +20 -20
  18. data/app/jobs/count_message_tokens_job.rb +39 -0
  19. data/app/jobs/passive_recall_job.rb +4 -4
  20. data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
  21. data/app/models/goal.rb +4 -4
  22. data/app/models/goal_pinned_message.rb +11 -0
  23. data/app/models/{event.rb → message.rb} +42 -39
  24. data/app/models/pinned_message.rb +41 -0
  25. data/app/models/session.rb +206 -198
  26. data/app/models/snapshot.rb +25 -25
  27. data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
  28. data/lib/agent_loop.rb +6 -6
  29. data/lib/analytical_brain/runner.rb +35 -35
  30. data/lib/analytical_brain/tools/activate_skill.rb +5 -9
  31. data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
  32. data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
  33. data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
  34. data/lib/analytical_brain/tools/finish_goal.rb +5 -8
  35. data/lib/analytical_brain/tools/read_workflow.rb +5 -9
  36. data/lib/analytical_brain/tools/rename_session.rb +3 -10
  37. data/lib/analytical_brain/tools/set_goal.rb +3 -7
  38. data/lib/analytical_brain/tools/update_goal.rb +3 -7
  39. data/lib/anima/settings.rb +15 -4
  40. data/lib/anima/version.rb +1 -1
  41. data/lib/events/bounce_back.rb +7 -7
  42. data/lib/events/subscribers/persister.rb +7 -7
  43. data/lib/events/subscribers/transient_broadcaster.rb +2 -2
  44. data/lib/mneme/compressed_viewport.rb +57 -57
  45. data/lib/mneme/l2_runner.rb +4 -4
  46. data/lib/mneme/passive_recall.rb +2 -2
  47. data/lib/mneme/runner.rb +57 -75
  48. data/lib/mneme/search.rb +38 -38
  49. data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
  50. data/lib/mneme/tools/everything_ok.rb +1 -3
  51. data/lib/mneme/tools/save_snapshot.rb +12 -16
  52. data/lib/tools/bash.rb +4 -12
  53. data/lib/tools/edit.rb +4 -6
  54. data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
  55. data/lib/tools/read.rb +4 -4
  56. data/lib/tools/registry.rb +1 -1
  57. data/lib/tools/remember.rb +46 -55
  58. data/lib/tools/spawn_specialist.rb +12 -23
  59. data/lib/tools/spawn_subagent.rb +9 -19
  60. data/lib/tools/subagent_prompts.rb +0 -2
  61. data/lib/tools/think.rb +3 -10
  62. data/lib/tools/web_get.rb +23 -4
  63. data/lib/tools/write.rb +3 -3
  64. data/lib/tui/cable_client.rb +3 -3
  65. data/lib/tui/message_store.rb +37 -37
  66. data/lib/tui/screens/chat.rb +27 -15
  67. data/skills/activerecord/SKILL.md +1 -1
  68. data/skills/dragonruby/SKILL.md +1 -1
  69. data/skills/draper-decorators/SKILL.md +1 -1
  70. data/skills/gh-issue.md +1 -1
  71. data/skills/mcp-server/SKILL.md +1 -1
  72. data/skills/ratatui-ruby/SKILL.md +1 -1
  73. data/skills/rspec/SKILL.md +1 -1
  74. data/templates/config.toml +16 -5
  75. data/templates/soul.md +7 -19
  76. data/workflows/create_handoff.md +1 -1
  77. data/workflows/create_note.md +1 -1
  78. data/workflows/create_plan.md +1 -1
  79. data/workflows/implement_plan.md +1 -1
  80. data/workflows/iterate_plan.md +1 -1
  81. data/workflows/research_codebase.md +1 -1
  82. data/workflows/resume_handoff.md +1 -1
  83. data/workflows/review_pr.md +78 -16
  84. data/workflows/thoughts_init.md +1 -1
  85. data/workflows/validate_plan.md +1 -1
  86. metadata +10 -9
  87. data/app/jobs/count_event_tokens_job.rb +0 -39
  88. data/app/models/goal_pinned_event.rb +0 -11
  89. data/app/models/pinned_event.rb +0 -41
  90. data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
@@ -5,18 +5,18 @@
5
5
  #
6
6
  # Supports two modes:
7
7
  #
8
- # **Immediate Persist (event_id provided):** The user event was already
8
+ # **Immediate Persist (message_id provided):** The user message was already
9
9
  # persisted and broadcast by the caller (e.g. {SessionChannel#speak}).
10
10
  # The job verifies LLM delivery — if the first API call fails, the
11
- # event is deleted and a {Events::BounceBack} is emitted so clients
11
+ # message is deleted and a {Events::BounceBack} is emitted so clients
12
12
  # can restore the text to the input field.
13
13
  #
14
- # **Standard (no event_id):** Processes already-persisted events (e.g.
14
+ # **Standard (no message_id):** Processes already-persisted messages (e.g.
15
15
  # after pending message promotion). Uses ActiveJob retry/discard for
16
16
  # error handling.
17
17
  #
18
- # @example Immediate Persist — event already saved by SessionChannel
19
- # AgentRequestJob.perform_later(session.id, event_id: 42)
18
+ # @example Immediate Persist — message already saved by SessionChannel
19
+ # AgentRequestJob.perform_later(session.id, message_id: 42)
20
20
  #
21
21
  # @example Standard — pending message processing
22
22
  # AgentRequestJob.perform_later(session.id)
@@ -49,8 +49,8 @@ class AgentRequestJob < ApplicationJob
49
49
  end
50
50
 
51
51
  # @param session_id [Integer] ID of the session to process
52
- # @param event_id [Integer, nil] ID of a pre-persisted user event (triggers delivery verification)
53
- def perform(session_id, event_id: nil)
52
+ # @param message_id [Integer, nil] ID of a pre-persisted user message (triggers delivery verification)
53
+ def perform(session_id, message_id: nil)
54
54
  session = Session.find(session_id)
55
55
 
56
56
  # Atomic: only one job processes a session at a time.
@@ -60,8 +60,8 @@ class AgentRequestJob < ApplicationJob
60
60
 
61
61
  agent_loop = AgentLoop.new(session: session)
62
62
 
63
- if event_id
64
- deliver_persisted_event(session, event_id, agent_loop)
63
+ if message_id
64
+ deliver_persisted_message(session, message_id, agent_loop)
65
65
  else
66
66
  agent_loop.run
67
67
  end
@@ -82,11 +82,11 @@ class AgentRequestJob < ApplicationJob
82
82
 
83
83
  private
84
84
 
85
- # Verifies LLM delivery for a pre-persisted user event.
85
+ # Verifies LLM delivery for a pre-persisted user message.
86
86
  #
87
- # The event was already created and broadcast by the caller, so
87
+ # The message was already created and broadcast by the caller, so
88
88
  # the user sees their message immediately. This method makes the
89
- # first LLM API call — if it fails, the event is deleted and a
89
+ # first LLM API call — if it fails, the message is deleted and a
90
90
  # {Events::BounceBack} notifies clients to remove the phantom
91
91
  # message and restore the text to the input field. For
92
92
  # {Providers::Anthropic::AuthenticationError}, an additional
@@ -102,26 +102,26 @@ class AgentRequestJob < ApplicationJob
102
102
  # execution, subsequent API calls).
103
103
  #
104
104
  # @param session [Session] the conversation session
105
- # @param event_id [Integer] database ID of the pre-persisted user event
105
+ # @param message_id [Integer] database ID of the pre-persisted user message
106
106
  # @param agent_loop [AgentLoop] agent loop instance (reused for continuation)
107
- def deliver_persisted_event(session, event_id, agent_loop)
108
- event = Event.find_by(id: event_id, session_id: session.id)
109
- # Event may have been deleted between SessionChannel#speak and job
107
+ def deliver_persisted_message(session, message_id, agent_loop)
108
+ message = Message.find_by(id: message_id, session_id: session.id)
109
+ # Message may have been deleted between SessionChannel#speak and job
110
110
  # execution (e.g. user recalled the message). Exit silently — there
111
111
  # is nothing to deliver or bounce back.
112
- return unless event
112
+ return unless message
113
113
 
114
- content = event.payload["content"]
114
+ content = message.payload["content"]
115
115
 
116
116
  begin
117
117
  agent_loop.deliver!
118
118
  rescue => error
119
- event.destroy!
119
+ message.destroy!
120
120
  Events::Bus.emit(Events::BounceBack.new(
121
121
  content: content,
122
122
  error: error.message,
123
123
  session_id: session.id,
124
- event_id: event_id
124
+ message_id: message_id
125
125
  ))
126
126
  broadcast_auth_required(session.id, error) if error.is_a?(Providers::Anthropic::AuthenticationError)
127
127
  return
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Counts tokens in a message's payload via the Anthropic API and
4
+ # caches the result on the message record. Enqueued automatically
5
+ # after each LLM message is created.
6
+ class CountMessageTokensJob < ApplicationJob
7
+ queue_as :default
8
+
9
+ retry_on Providers::Anthropic::Error, wait: :polynomially_longer, attempts: 3
10
+ discard_on ActiveRecord::RecordNotFound
11
+
12
+ # @param message_id [Integer] the Message record to count tokens for
13
+ def perform(message_id)
14
+ message = Message.find(message_id)
15
+ return if already_counted?(message)
16
+
17
+ provider = Providers::Anthropic.new
18
+ api_messages = [{role: message.api_role, content: message.payload["content"].to_s}]
19
+
20
+ token_count = provider.count_tokens(
21
+ model: Anima::Settings.model,
22
+ messages: api_messages
23
+ )
24
+
25
+ # Guard against parallel jobs: reload and re-check before writing.
26
+ # Uses update! (not update_all) so {Message::Broadcasting} after_update_commit
27
+ # broadcasts the updated token count to connected clients.
28
+ message.reload
29
+ return if already_counted?(message)
30
+
31
+ message.update!(token_count: token_count)
32
+ end
33
+
34
+ private
35
+
36
+ def already_counted?(message)
37
+ message.token_count > 0
38
+ end
39
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Runs passive recall after goal updates — searches event history for
3
+ # Runs passive recall after goal updates — searches message history for
4
4
  # context relevant to active goals and caches results on the session
5
5
  # for viewport injection.
6
6
  #
@@ -20,10 +20,10 @@ class PassiveRecallJob < ApplicationJob
20
20
  results = Mneme::PassiveRecall.new(session).call
21
21
 
22
22
  if results.any?
23
- session.update_column(:recalled_event_ids, results.map(&:event_id))
23
+ session.update_column(:recalled_message_ids, results.map(&:message_id))
24
24
  Mneme.logger.info("session=#{session_id} — passive recall found #{results.size} memories")
25
- elsif session.recalled_event_ids.present?
26
- session.update_column(:recalled_event_ids, [])
25
+ elsif session.recalled_message_ids.present?
26
+ session.update_column(:recalled_message_ids, [])
27
27
  end
28
28
  end
29
29
  end
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Broadcasts Event records to connected WebSocket clients via ActionCable.
4
- # Follows the Turbo Streams pattern: events are broadcast on both create
3
+ # Broadcasts Message records to connected WebSocket clients via ActionCable.
4
+ # Follows the Turbo Streams pattern: messages are broadcast on both create
5
5
  # and update, with an action type so clients can distinguish append from
6
6
  # replace operations.
7
7
  #
8
- # Each broadcast includes the Event's database ID, enabling clients to
8
+ # Each broadcast includes the Message's database ID, enabling clients to
9
9
  # maintain an ID-indexed store for efficient in-place updates (e.g. when
10
- # token counts arrive asynchronously from {CountEventTokensJob}).
10
+ # token counts arrive asynchronously from {CountMessageTokensJob}).
11
11
  #
12
- # When a new event pushes old events out of the LLM's context window,
13
- # the broadcast includes `evicted_event_ids` so clients can remove
12
+ # When a new message pushes old messages out of the LLM's context window,
13
+ # the broadcast includes `evicted_message_ids` so clients can remove
14
14
  # phantom messages that the agent no longer knows about.
15
15
  #
16
16
  # @example Create broadcast payload
@@ -24,7 +24,7 @@
24
24
  # {
25
25
  # "type" => "agent_message", "content" => "...", ...,
26
26
  # "id" => 99, "action" => "create",
27
- # "evicted_event_ids" => [101, 102, 103]
27
+ # "evicted_message_ids" => [101, 102, 103]
28
28
  # }
29
29
  #
30
30
  # @example Update broadcast payload (e.g. token count arrives)
@@ -33,7 +33,7 @@
33
33
  # "id" => 42, "action" => "update",
34
34
  # "rendered" => { "debug" => { "role" => "user", "content" => "hello", "tokens" => 15 } }
35
35
  # }
36
- module Event::Broadcasting
36
+ module Message::Broadcasting
37
37
  extend ActiveSupport::Concern
38
38
 
39
39
  ACTION_CREATE = "create"
@@ -47,26 +47,26 @@ module Event::Broadcasting
47
47
  private
48
48
 
49
49
  def broadcast_create
50
- broadcast_event(action: ACTION_CREATE)
50
+ broadcast_message(action: ACTION_CREATE)
51
51
  end
52
52
 
53
53
  def broadcast_update
54
- broadcast_event(action: ACTION_UPDATE)
54
+ broadcast_message(action: ACTION_UPDATE)
55
55
  end
56
56
 
57
- # Decorates the event for the session's current view mode and broadcasts
57
+ # Decorates the message for the session's current view mode and broadcasts
58
58
  # the payload to the session's ActionCable stream. Includes viewport
59
59
  # eviction metadata so clients can remove messages the LLM has forgotten.
60
60
  #
61
- # @param action [String] ACTION_CREATE or ACTION_UPDATE — tells clients how to handle the event
62
- def broadcast_event(action:)
61
+ # @param action [String] ACTION_CREATE or ACTION_UPDATE — tells clients how to handle the message
62
+ def broadcast_message(action:)
63
63
  return unless session_id
64
64
 
65
65
  session = Session.find_by(id: session_id)
66
66
  return unless session
67
67
 
68
68
  mode = session.view_mode
69
- decorator = EventDecorator.for(self)
69
+ decorator = MessageDecorator.for(self)
70
70
  broadcast_payload = payload.merge("id" => id, "action" => action)
71
71
 
72
72
  if decorator
@@ -74,11 +74,11 @@ module Event::Broadcasting
74
74
  end
75
75
 
76
76
  evicted_ids = session.recalculate_viewport!
77
- broadcast_payload["evicted_event_ids"] = evicted_ids if evicted_ids.any?
77
+ broadcast_payload["evicted_message_ids"] = evicted_ids if evicted_ids.any?
78
78
 
79
79
  # The nil? branch fires on every broadcast until boundary initializes, but
80
80
  # schedule_mneme! returns early after setting the boundary — cost is one DB read + write.
81
- session.schedule_mneme! if evicted_ids.any? || session.mneme_boundary_event_id.nil?
81
+ session.schedule_mneme! if evicted_ids.any? || session.mneme_boundary_message_id.nil?
82
82
 
83
83
  ActionCable.server.broadcast("session_#{session_id}", broadcast_payload)
84
84
  end
data/app/models/goal.rb CHANGED
@@ -13,8 +13,8 @@ class Goal < ApplicationRecord
13
13
  belongs_to :session
14
14
  belongs_to :parent_goal, class_name: "Goal", optional: true
15
15
  has_many :sub_goals, -> { order(:created_at) }, class_name: "Goal", foreign_key: :parent_goal_id, dependent: :destroy
16
- has_many :goal_pinned_events, dependent: :destroy
17
- has_many :pinned_events, through: :goal_pinned_events
16
+ has_many :goal_pinned_messages, dependent: :destroy
17
+ has_many :pinned_messages, through: :goal_pinned_messages
18
18
 
19
19
  validates :description, presence: true
20
20
  validates :status, inclusion: {in: STATUSES}
@@ -52,14 +52,14 @@ class Goal < ApplicationRecord
52
52
  sub_goals.active.update_all(status: "completed", completed_at: now, updated_at: now)
53
53
  end
54
54
 
55
- # Releases pinned events that have no remaining active Goal references
55
+ # Releases pinned messages that have no remaining active Goal references
56
56
  # anywhere in the session. Called after goal (and cascade) completion —
57
57
  # the orphaned scope checks all Goals, so pins shared with other active
58
58
  # Goals survive automatically via reference counting.
59
59
  #
60
60
  # @return [Integer] number of released pins
61
61
  def release_orphaned_pins!
62
- orphaned = session.pinned_events.orphaned
62
+ orphaned = session.pinned_messages.orphaned
63
63
  orphaned.destroy_all.size
64
64
  end
65
65
 
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Join record linking a {Goal} to a {PinnedMessage}. Many-to-many: one message
4
+ # can be pinned to multiple Goals, and one Goal can reference multiple pins.
5
+ # When the last Goal referencing a pin completes, the pin is released.
6
+ class GoalPinnedMessage < ApplicationRecord
7
+ belongs_to :goal
8
+ belongs_to :pinned_message
9
+
10
+ validates :pinned_message_id, uniqueness: {scope: :goal_id}
11
+ end
@@ -1,24 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # A persisted record of something that happened during a session.
4
- # Events are the single source of truth for conversation history —
5
- # there is no separate chat log, only events attached to a session.
3
+ # A persisted record of what was said during a session — by whom and when.
4
+ # Messages are the single source of truth for conversation history —
5
+ # there is no separate chat log, only messages attached to a session.
6
6
  #
7
- # @!attribute event_type
7
+ # Not to be confused with {Events::Base} (transient bus signals).
8
+ # Messages persist to SQLite; events flow through the bus and are gone.
9
+ #
10
+ # @!attribute message_type
8
11
  # @return [String] one of {TYPES}: system_message, user_message,
9
12
  # agent_message, tool_call, tool_response
10
13
  # @!attribute payload
11
- # @return [Hash] event-specific data (content, tool_name, tool_input, etc.)
14
+ # @return [Hash] message-specific data (content, tool_name, tool_input, etc.)
12
15
  # @!attribute timestamp
13
16
  # @return [Integer] nanoseconds since epoch (Process::CLOCK_REALTIME)
14
17
  # @!attribute token_count
15
- # @return [Integer] cached token count for this event's payload (0 until counted)
18
+ # @return [Integer] cached token count for this message's payload (0 until counted)
16
19
  # @!attribute tool_use_id
17
- # @return [String] ID correlating tool_call and tool_response events
20
+ # @return [String] ID correlating tool_call and tool_response messages
18
21
  # (Anthropic-assigned, or a SecureRandom.uuid fallback when the API returns nil;
19
- # required for tool_call and tool_response events)
20
- class Event < ApplicationRecord
21
- include Event::Broadcasting
22
+ # required for tool_call and tool_response messages)
23
+ class Message < ApplicationRecord
24
+ include Message::Broadcasting
22
25
 
23
26
  TYPES = %w[system_message user_message agent_message tool_call tool_response].freeze
24
27
  LLM_TYPES = %w[user_message agent_message].freeze
@@ -28,7 +31,7 @@ class Event < ApplicationRecord
28
31
  SPAWN_TOOLS = %w[spawn_subagent spawn_specialist].freeze
29
32
  PENDING_STATUS = "pending"
30
33
 
31
- # Event types that require a tool_use_id to pair call with response.
34
+ # Message types that require a tool_use_id to pair call with response.
32
35
  TOOL_TYPES = %w[tool_call tool_response].freeze
33
36
 
34
37
  ROLE_MAP = {"user_message" => "user", "agent_message" => "assistant"}.freeze
@@ -37,25 +40,25 @@ class Event < ApplicationRecord
37
40
  BYTES_PER_TOKEN = 4
38
41
 
39
42
  belongs_to :session
40
- has_many :pinned_events, dependent: :destroy
43
+ has_many :pinned_messages, dependent: :destroy
41
44
 
42
- validates :event_type, presence: true, inclusion: {in: TYPES}
45
+ validates :message_type, presence: true, inclusion: {in: TYPES}
43
46
  validates :payload, presence: true
44
47
  validates :timestamp, presence: true
45
48
  # Anthropic requires every tool_use to have a matching tool_result with the same ID
46
- validates :tool_use_id, presence: true, if: -> { event_type.in?(TOOL_TYPES) }
49
+ validates :tool_use_id, presence: true, if: -> { message_type.in?(TOOL_TYPES) }
47
50
 
48
51
  after_create :schedule_token_count, if: :llm_message?
49
52
 
50
53
  # @!method self.llm_messages
51
- # Events that represent conversation turns sent to the LLM API.
54
+ # Messages that represent conversation turns sent to the LLM API.
52
55
  # @return [ActiveRecord::Relation]
53
- scope :llm_messages, -> { where(event_type: LLM_TYPES) }
56
+ scope :llm_messages, -> { where(message_type: LLM_TYPES) }
54
57
 
55
- # @!method self.context_events
56
- # Events included in the LLM context window (messages + tool interactions).
58
+ # @!method self.context_messages
59
+ # Messages included in the LLM context window (conversation + tool interactions).
57
60
  # @return [ActiveRecord::Relation]
58
- scope :context_events, -> { where(event_type: CONTEXT_TYPES) }
61
+ scope :context_messages, -> { where(message_type: CONTEXT_TYPES) }
59
62
 
60
63
  # @!method self.pending
61
64
  # User messages queued during active agent processing, not yet sent to LLM.
@@ -63,36 +66,36 @@ class Event < ApplicationRecord
63
66
  scope :pending, -> { where(status: PENDING_STATUS) }
64
67
 
65
68
  # @!method self.deliverable
66
- # Events eligible for LLM context (excludes pending messages).
69
+ # Messages eligible for LLM context (excludes pending messages).
67
70
  # NULL status means delivered/processed — the only excluded value is "pending".
68
71
  # @return [ActiveRecord::Relation]
69
72
  scope :deliverable, -> { where(status: nil) }
70
73
 
71
- # @!method self.excluding_spawn_events
72
- # Excludes spawn_subagent/spawn_specialist tool_call and tool_response events.
73
- # Used when building parent context for sub-agents — spawn events cause role
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
74
77
  # confusion because the sub-agent sees sibling spawn results and mistakes
75
78
  # itself for the parent.
76
79
  # @return [ActiveRecord::Relation]
77
- scope :excluding_spawn_events, -> {
78
- where.not("event_type IN (?) AND json_extract(payload, '$.tool_name') IN (?)",
80
+ scope :excluding_spawn_messages, -> {
81
+ where.not("message_type IN (?) AND json_extract(payload, '$.tool_name') IN (?)",
79
82
  TOOL_TYPES, SPAWN_TOOLS)
80
83
  }
81
84
 
82
- # Maps event_type to the Anthropic Messages API role.
85
+ # Maps message_type to the Anthropic Messages API role.
83
86
  # @return [String] "user" or "assistant"
84
87
  def api_role
85
- ROLE_MAP.fetch(event_type)
88
+ ROLE_MAP.fetch(message_type)
86
89
  end
87
90
 
88
- # @return [Boolean] true if this event represents an LLM conversation turn
91
+ # @return [Boolean] true if this message represents an LLM conversation turn
89
92
  def llm_message?
90
- event_type.in?(LLM_TYPES)
93
+ message_type.in?(LLM_TYPES)
91
94
  end
92
95
 
93
- # @return [Boolean] true if this event is part of the LLM context window
94
- def context_event?
95
- event_type.in?(CONTEXT_TYPES)
96
+ # @return [Boolean] true if this message is part of the LLM context window
97
+ def context_message?
98
+ message_type.in?(CONTEXT_TYPES)
96
99
  end
97
100
 
98
101
  # @return [Boolean] true if this is a pending message not yet sent to the LLM
@@ -100,20 +103,20 @@ class Event < ApplicationRecord
100
103
  status == PENDING_STATUS
101
104
  end
102
105
 
103
- # @return [Boolean] true if this is a conversation event (user/agent/system message)
104
- # or a think tool_call — the events Mneme treats as "conversation" for boundary tracking
106
+ # @return [Boolean] true if this is a conversation message (user/agent/system)
107
+ # or a think tool_call — the messages Mneme treats as "conversation" for boundary tracking
105
108
  def conversation_or_think?
106
- event_type.in?(CONVERSATION_TYPES) ||
107
- (event_type == "tool_call" && payload["tool_name"] == THINK_TOOL)
109
+ message_type.in?(CONVERSATION_TYPES) ||
110
+ (message_type == "tool_call" && payload["tool_name"] == THINK_TOOL)
108
111
  end
109
112
 
110
113
  # Heuristic token estimate: ~4 bytes per token for English prose.
111
- # Tool events are estimated from the full payload JSON since tool_input
114
+ # Tool messages are estimated from the full payload JSON since tool_input
112
115
  # and tool metadata contribute to token count. Messages use content only.
113
116
  #
114
117
  # @return [Integer] estimated token count (at least 1)
115
118
  def estimate_tokens
116
- text = if event_type.in?(TOOL_TYPES)
119
+ text = if message_type.in?(TOOL_TYPES)
117
120
  payload.to_json
118
121
  else
119
122
  payload["content"].to_s
@@ -124,6 +127,6 @@ class Event < ApplicationRecord
124
127
  private
125
128
 
126
129
  def schedule_token_count
127
- CountEventTokensJob.perform_later(id)
130
+ CountMessageTokensJob.perform_later(id)
128
131
  end
129
132
  end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A conversation message pinned to one or more Goals by Mneme to protect it
4
+ # from viewport eviction. Pinned messages appear in the Goals section of
5
+ # the viewport, giving the main agent access to critical context that
6
+ # would otherwise scroll out of the sliding window.
7
+ #
8
+ # Pinning is goal-scoped: when all Goals referencing a pin complete,
9
+ # the pin is automatically released (reference-counted cleanup).
10
+ #
11
+ # @!attribute display_text
12
+ # @return [String] truncated message content (~200 chars) shown in the Goals section
13
+ class PinnedMessage < ApplicationRecord
14
+ # Display text limit — enough to recognize content, cheap on tokens.
15
+ MAX_DISPLAY_TEXT_LENGTH = 200
16
+
17
+ belongs_to :message
18
+
19
+ has_many :goal_pinned_messages, dependent: :destroy
20
+ has_many :goals, through: :goal_pinned_messages
21
+
22
+ validates :display_text, presence: true, length: {maximum: MAX_DISPLAY_TEXT_LENGTH}
23
+ validates :message_id, uniqueness: true
24
+
25
+ # Pinned messages with no remaining active goals — safe to release.
26
+ #
27
+ # @return [ActiveRecord::Relation]
28
+ scope :orphaned, -> {
29
+ where.not(
30
+ "EXISTS (SELECT 1 FROM goal_pinned_messages gpm " \
31
+ "JOIN goals ON goals.id = gpm.goal_id " \
32
+ "WHERE gpm.pinned_message_id = pinned_messages.id " \
33
+ "AND goals.status = 'active')"
34
+ )
35
+ }
36
+
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
40
+ end
41
+ end