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
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # A conversation session — the fundamental unit of agent interaction.
4
- # Owns an ordered stream of {Event} records representing everything
4
+ # Owns an ordered stream of {Message} records representing everything
5
5
  # that happened: user messages, agent responses, tool calls, etc.
6
6
  #
7
7
  # Sessions form a hierarchy: a main session can spawn child sessions
@@ -15,10 +15,10 @@ class Session < ApplicationRecord
15
15
 
16
16
  serialize :granted_tools, coder: JSON
17
17
 
18
- has_many :events, -> { order(:id) }, dependent: :destroy
18
+ has_many :messages, -> { order(:id) }, dependent: :destroy
19
19
  has_many :goals, dependent: :destroy
20
20
  has_many :snapshots, dependent: :destroy
21
- has_many :pinned_events, through: :events
21
+ has_many :pinned_messages, through: :messages
22
22
 
23
23
  belongs_to :parent_session, class_name: "Session", optional: true
24
24
  has_many :child_sessions, class_name: "Session", foreign_key: :parent_session_id, dependent: :destroy
@@ -46,34 +46,34 @@ class Session < ApplicationRecord
46
46
  parent_session_id.present?
47
47
  end
48
48
 
49
- # Checks whether the Mneme terminal event has left the viewport and
50
- # enqueues {MnemeJob} when it has. On the first event of a new session,
49
+ # Checks whether the Mneme terminal message has left the viewport and
50
+ # enqueues {MnemeJob} when it has. On the first message of a new session,
51
51
  # initializes the boundary pointer.
52
52
  #
53
- # The terminal event is always a conversation event (user/agent message
53
+ # The terminal message is always a conversation message (user/agent message
54
54
  # or think tool_call), never a bare tool_call/tool_response.
55
55
  #
56
56
  # @return [void]
57
57
  def schedule_mneme!
58
58
  return if sub_agent?
59
59
 
60
- # Initialize boundary on first conversation event
61
- if mneme_boundary_event_id.nil?
62
- first_conversation = events.deliverable
63
- .where(event_type: Event::CONVERSATION_TYPES)
60
+ # Initialize boundary on first conversation message
61
+ if mneme_boundary_message_id.nil?
62
+ first_conversation = messages.deliverable
63
+ .where(message_type: Message::CONVERSATION_TYPES)
64
64
  .order(:id).first
65
- first_conversation ||= events.deliverable
66
- .where(event_type: "tool_call")
67
- .detect { |e| e.payload["tool_name"] == Event::THINK_TOOL }
65
+ first_conversation ||= messages.deliverable
66
+ .where(message_type: "tool_call")
67
+ .detect { |msg| msg.payload["tool_name"] == Message::THINK_TOOL }
68
68
 
69
69
  if first_conversation
70
- update_column(:mneme_boundary_event_id, first_conversation.id)
70
+ update_column(:mneme_boundary_message_id, first_conversation.id)
71
71
  end
72
72
  return
73
73
  end
74
74
 
75
- # Check if boundary event has left the viewport
76
- return if viewport_event_ids.include?(mneme_boundary_event_id)
75
+ # Check if boundary message has left the viewport
76
+ return if viewport_message_ids.include?(mneme_boundary_message_id)
77
77
 
78
78
  MnemeJob.perform_later(id)
79
79
  end
@@ -89,7 +89,7 @@ class Session < ApplicationRecord
89
89
  def schedule_analytical_brain!
90
90
  return if sub_agent?
91
91
 
92
- count = events.llm_messages.count
92
+ count = messages.llm_messages.count
93
93
  return if count < 2
94
94
  # Already named — only regenerate at interval boundaries (30, 60, 90, …)
95
95
  return if name.present? && (count % Anima::Settings.name_generation_interval != 0)
@@ -97,43 +97,43 @@ class Session < ApplicationRecord
97
97
  AnalyticalBrainJob.perform_later(id)
98
98
  end
99
99
 
100
- # Returns the events currently visible in the LLM context window.
101
- # Walks events newest-first and includes them until the token budget
102
- # is exhausted. Events are full-size or excluded entirely.
100
+ # Returns the messages currently visible in the LLM context window.
101
+ # Walks messages newest-first and includes them until the token budget
102
+ # is exhausted. Messages are full-size or excluded entirely.
103
103
  #
104
104
  # Sub-agent sessions inherit parent context via virtual viewport:
105
- # child events are prioritized and fill the budget first (newest-first),
106
- # then parent events from before the fork point fill the remaining budget.
107
- # The final array is chronological: parent events first, then child events.
105
+ # child messages are prioritized and fill the budget first (newest-first),
106
+ # then parent messages from before the fork point fill the remaining budget.
107
+ # The final array is chronological: parent messages first, then child messages.
108
108
  #
109
109
  # @param token_budget [Integer] maximum tokens to include (positive)
110
110
  # @param include_pending [Boolean] whether to include pending messages (true for
111
111
  # display, false for LLM context assembly)
112
- # @return [Array<Event>] chronologically ordered
113
- def viewport_events(token_budget: Anima::Settings.token_budget, include_pending: true)
114
- own_events = select_events(own_event_scope(include_pending), budget: token_budget)
115
- remaining = token_budget - own_events.sum { |e| event_token_cost(e) }
112
+ # @return [Array<Message>] chronologically ordered
113
+ def viewport_messages(token_budget: Anima::Settings.token_budget, include_pending: true)
114
+ own = select_messages(own_message_scope(include_pending), budget: token_budget)
115
+ remaining = token_budget - own.sum { |msg| message_token_cost(msg) }
116
116
 
117
117
  if sub_agent? && remaining > 0
118
- parent_events = select_events(parent_event_scope(include_pending), budget: remaining)
119
- trim_trailing_tool_calls(parent_events) + own_events
118
+ parent = select_messages(parent_message_scope(include_pending), budget: remaining)
119
+ trim_trailing_tool_calls(parent) + own
120
120
  else
121
- own_events
121
+ own
122
122
  end
123
123
  end
124
124
 
125
- # Recalculates the viewport and returns IDs of events evicted since the
126
- # last snapshot. Updates the stored viewport_event_ids atomically.
127
- # Piggybacks on event broadcasts to notify clients which messages left
125
+ # Recalculates the viewport and returns IDs of messages evicted since the
126
+ # last snapshot. Updates the stored viewport_message_ids atomically.
127
+ # Piggybacks on message broadcasts to notify clients which messages left
128
128
  # the LLM's context window.
129
129
  #
130
- # @return [Array<Integer>] IDs of events no longer in the viewport
130
+ # @return [Array<Integer>] IDs of messages no longer in the viewport
131
131
  def recalculate_viewport!
132
- new_ids = viewport_events.map(&:id)
133
- old_ids = viewport_event_ids
132
+ new_ids = viewport_messages.map(&:id)
133
+ old_ids = viewport_message_ids
134
134
 
135
135
  evicted = old_ids - new_ids
136
- update_column(:viewport_event_ids, new_ids) if old_ids != new_ids
136
+ update_column(:viewport_message_ids, new_ids) if old_ids != new_ids
137
137
  evicted
138
138
  end
139
139
 
@@ -142,10 +142,10 @@ class Session < ApplicationRecord
142
142
  # where eviction notifications are unnecessary (clients clear their
143
143
  # store first).
144
144
  #
145
- # @param ids [Array<Integer>] event IDs now in the viewport
145
+ # @param ids [Array<Integer>] message IDs now in the viewport
146
146
  # @return [void]
147
147
  def snapshot_viewport!(ids)
148
- update_column(:viewport_event_ids, ids)
148
+ update_column(:viewport_message_ids, ids)
149
149
  end
150
150
 
151
151
  # Returns the system prompt for this session.
@@ -217,14 +217,14 @@ class Session < ApplicationRecord
217
217
  save!
218
218
  end
219
219
 
220
- # Assembles the system prompt: soul first, then environment context,
221
- # then skills/workflow, then goals.
220
+ # Assembles the system prompt: version preamble, soul, environment context,
221
+ # skills/workflow, then goals.
222
222
  # The soul is always present — "who am I" before "what can I do."
223
223
  #
224
224
  # @param environment_context [String, nil] pre-assembled environment block
225
225
  # @return [String] composed system prompt
226
226
  def assemble_system_prompt(environment_context: nil)
227
- [assemble_soul_section, environment_context, assemble_expertise_section, assemble_goals_section].compact.join("\n\n")
227
+ [assemble_version_preamble, assemble_soul_section, environment_context, assemble_expertise_section, assemble_goals_section].compact.join("\n\n")
228
228
  end
229
229
 
230
230
  # Serializes active goals as a lightweight summary for ActionCable
@@ -238,17 +238,17 @@ class Session < ApplicationRecord
238
238
 
239
239
  # Builds the message array expected by the Anthropic Messages API.
240
240
  # Viewport layout (top to bottom):
241
- # [L2 snapshots] [L1 snapshots] [pinned events] [recalled memories] [sliding window events]
241
+ # [L2 snapshots] [L1 snapshots] [pinned messages] [recalled memories] [sliding window messages]
242
242
  #
243
- # Snapshots appear ONLY after their source events have evicted from
243
+ # Snapshots appear ONLY after their source messages have evicted from
244
244
  # the sliding window. L1 snapshots drop once covered by an L2 snapshot.
245
- # Pinned events are critical context attached to active Goals — they
245
+ # Pinned messages are critical context attached to active Goals — they
246
246
  # survive eviction intact until their Goals complete.
247
- # Recalled memories surface relevant older events (passive recall via goals).
247
+ # Recalled memories surface relevant older messages (passive recall via goals).
248
248
  # Each layer has a fixed token budget fraction — snapshots, pins, and recall
249
249
  # consume viewport space, reducing the sliding window size.
250
250
  #
251
- # Sub-agent sessions skip snapshot/pin/recall injection (they inherit parent events directly).
251
+ # Sub-agent sessions skip snapshot/pin/recall injection (they inherit parent messages directly).
252
252
  #
253
253
  # @param token_budget [Integer] maximum tokens to include (positive)
254
254
  # @return [Array<Hash>] Anthropic Messages API format
@@ -268,25 +268,25 @@ class Session < ApplicationRecord
268
268
  sliding_budget = token_budget - l2_budget - l1_budget - pinned_budget - recall_budget
269
269
  end
270
270
 
271
- events = viewport_events(token_budget: sliding_budget, include_pending: false)
271
+ window = viewport_messages(token_budget: sliding_budget, include_pending: false)
272
272
 
273
273
  unless sub_agent?
274
- first_event_id = events.first&.id
275
- snapshot_messages = assemble_snapshot_messages(first_event_id, l2_budget: l2_budget, l1_budget: l1_budget)
276
- pinned_messages = assemble_pinned_event_messages(first_event_id, budget: pinned_budget)
274
+ first_message_id = window.first&.id
275
+ snapshot_messages = assemble_snapshot_messages(first_message_id, l2_budget: l2_budget, l1_budget: l1_budget)
276
+ pinned_messages = assemble_pinned_section_messages(first_message_id, budget: pinned_budget)
277
277
  recall_messages = assemble_recall_messages(budget: recall_budget)
278
278
  end
279
279
 
280
- snapshot_messages + pinned_messages + recall_messages + assemble_messages(ensure_atomic_tool_pairs(events))
280
+ snapshot_messages + pinned_messages + recall_messages + assemble_messages(ensure_atomic_tool_pairs(window))
281
281
  end
282
282
 
283
- # Detects orphaned tool_call events (those without a matching tool_response
283
+ # Detects orphaned tool_call messages (those without a matching tool_response
284
284
  # and whose timeout has expired) and creates synthetic error responses.
285
285
  # An orphaned tool_call permanently breaks the session because the
286
286
  # Anthropic API rejects conversations where a tool_use block has no
287
287
  # matching tool_result.
288
288
  #
289
- # Respects the per-call timeout stored in the tool_call event payload —
289
+ # Respects the per-call timeout stored in the tool_call message payload —
290
290
  # a tool_call is only healed after its deadline has passed. This avoids
291
291
  # prematurely healing long-running tools that the agent intentionally
292
292
  # gave an extended timeout.
@@ -294,8 +294,8 @@ class Session < ApplicationRecord
294
294
  # @return [Integer] number of synthetic responses created
295
295
  def heal_orphaned_tool_calls!
296
296
  now_ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
297
- responded_ids = events.where(event_type: "tool_response").select(:tool_use_id)
298
- unresponded = events.where(event_type: "tool_call")
297
+ responded_ids = messages.where(message_type: "tool_response").select(:tool_use_id)
298
+ unresponded = messages.where(message_type: "tool_call")
299
299
  .where.not(tool_use_id: responded_ids)
300
300
 
301
301
  healed = 0
@@ -304,8 +304,8 @@ class Session < ApplicationRecord
304
304
  deadline_ns = orphan.timestamp + (timeout * 1_000_000_000)
305
305
  next if now_ns < deadline_ns
306
306
 
307
- events.create!(
308
- event_type: "tool_response",
307
+ messages.create!(
308
+ message_type: "tool_response",
309
309
  payload: {
310
310
  "type" => "tool_response",
311
311
  "content" => "Tool execution timed out after #{timeout} seconds — no result was returned.",
@@ -323,14 +323,14 @@ class Session < ApplicationRecord
323
323
 
324
324
  # Delivers a user message respecting the session's processing state.
325
325
  #
326
- # When idle, persists the event directly and enqueues {AgentRequestJob}
326
+ # When idle, persists the message directly and enqueues {AgentRequestJob}
327
327
  # to process it. When mid-turn ({#processing?}), emits a pending
328
328
  # {Events::UserMessage} via {Events::Bus} so it queues until the
329
329
  # current agent loop completes — preventing interleaving between
330
330
  # tool_use/tool_result pairs.
331
331
  #
332
332
  # @param content [String] user message text
333
- # @param bounce_back [Boolean] when true, passes +event_id+ to the job
333
+ # @param bounce_back [Boolean] when true, passes +message_id+ to the job
334
334
  # so failed LLM delivery triggers a {Events::BounceBack} (used by
335
335
  # {SessionChannel#speak} for immediate-display messages)
336
336
  # @return [void]
@@ -338,16 +338,16 @@ class Session < ApplicationRecord
338
338
  if processing?
339
339
  Events::Bus.emit(Events::UserMessage.new(
340
340
  content: content, session_id: id,
341
- status: Event::PENDING_STATUS
341
+ status: Message::PENDING_STATUS
342
342
  ))
343
343
  else
344
- event = create_user_event(content)
345
- job_args = bounce_back ? {event_id: event.id} : {}
344
+ msg = create_user_message(content)
345
+ job_args = bounce_back ? {message_id: msg.id} : {}
346
346
  AgentRequestJob.perform_later(id, **job_args)
347
347
  end
348
348
  end
349
349
 
350
- # Persists a user message event directly, bypassing the pending queue.
350
+ # Persists a user message directly, bypassing the pending queue.
351
351
  #
352
352
  # Used by {#enqueue_user_message} (idle path), {AgentLoop#process},
353
353
  # and sub-agent spawn tools ({Tools::SpawnSubagent}, {Tools::SpawnSpecialist})
@@ -355,11 +355,11 @@ class Session < ApplicationRecord
355
355
  # messages — these callers own the persistence lifecycle.
356
356
  #
357
357
  # @param content [String] user message text
358
- # @return [Event] the persisted event record
359
- def create_user_event(content)
358
+ # @return [Message] the persisted message record
359
+ def create_user_message(content)
360
360
  now = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
361
- events.create!(
362
- event_type: "user_message",
361
+ messages.create!(
362
+ message_type: "user_message",
363
363
  payload: {type: "user_message", content: content, session_id: id, timestamp: now},
364
364
  timestamp: now
365
365
  )
@@ -367,13 +367,13 @@ class Session < ApplicationRecord
367
367
 
368
368
  # Promotes all pending user messages to delivered status so they
369
369
  # appear in the next LLM context. Triggers broadcast_update for
370
- # each event so connected clients refresh the pending indicator.
370
+ # each message so connected clients refresh the pending indicator.
371
371
  #
372
372
  # @return [Integer] number of promoted messages
373
373
  def promote_pending_messages!
374
374
  promoted = 0
375
- events.where(event_type: "user_message", status: Event::PENDING_STATUS).find_each do |event|
376
- event.update!(status: nil, payload: event.payload.except("status"))
375
+ messages.where(message_type: "user_message", status: Message::PENDING_STATUS).find_each do |msg|
376
+ msg.update!(status: nil, payload: msg.payload.except("status"))
377
377
  promoted += 1
378
378
  end
379
379
  promoted
@@ -402,6 +402,14 @@ class Session < ApplicationRecord
402
402
 
403
403
  private
404
404
 
405
+ # One-line version preamble so the agent knows its own version.
406
+ # Useful for commits, handoffs, and debugging.
407
+ #
408
+ # @return [String] e.g. "You are running on Anima v1.1.3"
409
+ def assemble_version_preamble
410
+ "You are running on Anima v#{Anima::VERSION}"
411
+ end
412
+
405
413
  # Reads the soul file — the agent's self-authored identity.
406
414
  # Loaded as the first section of every system prompt, before skills,
407
415
  # workflows, and goals.
@@ -520,40 +528,40 @@ class Session < ApplicationRecord
520
528
  })
521
529
  end
522
530
 
523
- # Scopes own events for viewport assembly.
531
+ # Scopes own messages for viewport assembly.
524
532
  # @return [ActiveRecord::Relation]
525
- def own_event_scope(include_pending)
526
- scope = events.context_events
533
+ def own_message_scope(include_pending)
534
+ scope = messages.context_messages
527
535
  include_pending ? scope : scope.deliverable
528
536
  end
529
537
 
530
- # Scopes parent events created before this session's fork point.
531
- # Excludes spawn tool events — sub-agents don't need to see sibling
538
+ # Scopes parent messages created before this session's fork point.
539
+ # Excludes spawn tool messages — sub-agents don't need to see sibling
532
540
  # spawn pairs, which cause role confusion (the sub-agent mistakes
533
541
  # itself for the parent when it sees "Specialist @sibling spawned...").
534
542
  # @return [ActiveRecord::Relation]
535
- def parent_event_scope(include_pending)
536
- scope = parent_session.events.context_events
537
- .excluding_spawn_events
543
+ def parent_message_scope(include_pending)
544
+ scope = parent_session.messages.context_messages
545
+ .excluding_spawn_messages
538
546
  .where(created_at: ...created_at)
539
547
  include_pending ? scope : scope.deliverable
540
548
  end
541
549
 
542
- # Walks events newest-first, selecting until the token budget is exhausted.
543
- # Always includes at least the newest event even if it exceeds budget.
550
+ # Walks messages newest-first, selecting until the token budget is exhausted.
551
+ # Always includes at least the newest message even if it exceeds budget.
544
552
  #
545
- # @param scope [ActiveRecord::Relation] event scope to select from
553
+ # @param scope [ActiveRecord::Relation] message scope to select from
546
554
  # @param budget [Integer] maximum tokens to include
547
- # @return [Array<Event>] chronologically ordered
548
- def select_events(scope, budget:)
555
+ # @return [Array<Message>] chronologically ordered
556
+ def select_messages(scope, budget:)
549
557
  selected = []
550
558
  remaining = budget
551
559
 
552
- scope.reorder(id: :desc).each do |event|
553
- cost = event_token_cost(event)
560
+ scope.reorder(id: :desc).each do |msg|
561
+ cost = message_token_cost(msg)
554
562
  break if cost > remaining && selected.any?
555
563
 
556
- selected << event
564
+ selected << msg
557
565
  remaining -= cost
558
566
  end
559
567
 
@@ -561,59 +569,59 @@ class Session < ApplicationRecord
561
569
  end
562
570
 
563
571
  # @return [Integer] token cost, using cached count or heuristic estimate
564
- def event_token_cost(event)
565
- (event.token_count > 0) ? event.token_count : estimate_tokens(event)
572
+ def message_token_cost(msg)
573
+ (msg.token_count > 0) ? msg.token_count : estimate_tokens(msg)
566
574
  end
567
575
 
568
- # Removes trailing tool_call events that lack matching tool_response.
576
+ # Removes trailing tool_call messages that lack matching tool_response.
569
577
  # Prevents orphaned tool_use blocks at the parent/child viewport boundary
570
578
  # (the spawn_subagent/spawn_specialist tool_call is emitted before the child exists,
571
579
  # but its tool_response comes after — so the cutoff can split them).
572
- def trim_trailing_tool_calls(event_list)
573
- event_list.pop while event_list.last&.event_type == "tool_call"
574
- event_list
580
+ def trim_trailing_tool_calls(message_list)
581
+ message_list.pop while message_list.last&.message_type == "tool_call"
582
+ message_list
575
583
  end
576
584
 
577
- # Ensures every tool_call in the event list has a matching tool_response
578
- # (and vice versa) by removing unpaired events. The Anthropic API requires
585
+ # Ensures every tool_call in the message list has a matching tool_response
586
+ # (and vice versa) by removing unpaired messages. The Anthropic API requires
579
587
  # every tool_use block to have a tool_result — a missing partner causes
580
588
  # a permanent API error. Token budget cutoffs can split pairs when the
581
589
  # boundary falls between a tool_call and its tool_response.
582
590
  #
583
- # @param event_list [Array<Event>] chronologically ordered events
584
- # @return [Array<Event>] events with unpaired tool events removed
585
- def ensure_atomic_tool_pairs(event_list)
586
- tool_events = event_list.select { |e| e.tool_use_id.present? }
587
- return event_list if tool_events.empty?
588
-
589
- paired = tool_events.group_by(&:tool_use_id)
590
- complete_ids = paired.each_with_object(Set.new) do |(id, evts), set|
591
- has_call = evts.any? { |e| e.event_type == "tool_call" }
592
- has_response = evts.any? { |e| e.event_type == "tool_response" }
593
- set << id if has_call && has_response
591
+ # @param message_list [Array<Message>] chronologically ordered messages
592
+ # @return [Array<Message>] messages with unpaired tool messages removed
593
+ def ensure_atomic_tool_pairs(message_list)
594
+ tool_msgs = message_list.select { |m| m.tool_use_id.present? }
595
+ return message_list if tool_msgs.empty?
596
+
597
+ paired = tool_msgs.group_by(&:tool_use_id)
598
+ complete_ids = paired.each_with_object(Set.new) do |(uid, msgs), set|
599
+ has_call = msgs.any? { |m| m.message_type == "tool_call" }
600
+ has_response = msgs.any? { |m| m.message_type == "tool_response" }
601
+ set << uid if has_call && has_response
594
602
  end
595
603
 
596
- event_list.reject { |e| e.tool_use_id.present? && !complete_ids.include?(e.tool_use_id) }
604
+ message_list.reject { |m| m.tool_use_id.present? && !complete_ids.include?(m.tool_use_id) }
597
605
  end
598
606
 
599
607
  # Selects visible snapshots and formats them as Anthropic messages.
600
- # Snapshots are visible when their source events have fully evicted.
608
+ # Snapshots are visible when their source messages have fully evicted.
601
609
  # L1 snapshots are excluded when covered by an L2 snapshot.
602
610
  #
603
- # @param first_event_id [Integer, nil] first event ID in the sliding window
611
+ # @param first_message_id [Integer, nil] first message ID in the sliding window
604
612
  # @param l2_budget [Integer] token budget for L2 snapshots
605
613
  # @param l1_budget [Integer] token budget for L1 snapshots
606
614
  # @return [Array<Hash>] Anthropic Messages API format
607
- def assemble_snapshot_messages(first_event_id, l2_budget:, l1_budget:)
608
- return [] unless first_event_id
615
+ def assemble_snapshot_messages(first_message_id, l2_budget:, l1_budget:)
616
+ return [] unless first_message_id
609
617
 
610
618
  l2_messages = select_snapshots_within_budget(
611
- snapshots.for_level(2).source_events_evicted(first_event_id).chronological,
619
+ snapshots.for_level(2).source_messages_evicted(first_message_id).chronological,
612
620
  budget: l2_budget
613
621
  ).map { |snapshot| format_snapshot_message(snapshot, label: "long-term memory") }
614
622
 
615
623
  l1_messages = select_snapshots_within_budget(
616
- snapshots.for_level(1).not_covered_by_l2.source_events_evicted(first_event_id).chronological,
624
+ snapshots.for_level(1).not_covered_by_l2.source_messages_evicted(first_message_id).chronological,
617
625
  budget: l1_budget
618
626
  ).map { |snapshot| format_snapshot_message(snapshot, label: "recent memory") }
619
627
 
@@ -651,39 +659,39 @@ class Session < ApplicationRecord
651
659
  {role: "user", content: "[#{label}]\n#{snapshot.text}"}
652
660
  end
653
661
 
654
- # Assembles pinned events as a Goals section message for the viewport.
655
- # Only includes pinned events whose source event has evicted from the
656
- # sliding window (same rule as snapshots — no duplication with live events).
662
+ # Assembles pinned messages as a Goals section message for the viewport.
663
+ # Only includes pinned messages whose source message has evicted from the
664
+ # sliding window (same rule as snapshots — no duplication with live messages).
657
665
  #
658
- # Deduplication: the first Goal referencing an event shows its truncated
659
- # display_text; subsequent Goals show a bare `event N` ID to save tokens.
666
+ # Deduplication: the first Goal referencing a message shows its truncated
667
+ # display_text; subsequent Goals show a bare `message N` ID to save tokens.
660
668
  #
661
- # @param first_event_id [Integer, nil] first event ID in the sliding window
662
- # @param budget [Integer] token budget for pinned events
669
+ # @param first_message_id [Integer, nil] first message ID in the sliding window
670
+ # @param budget [Integer] token budget for pinned messages
663
671
  # @return [Array<Hash>] Anthropic Messages API format (0 or 1 messages)
664
- def assemble_pinned_event_messages(first_event_id, budget:)
665
- return [] unless first_event_id
672
+ def assemble_pinned_section_messages(first_message_id, budget:)
673
+ return [] unless first_message_id
666
674
 
667
- pins = pinned_events
668
- .includes(:event, :goals)
669
- .where("pinned_events.event_id < ?", first_event_id)
670
- .order("pinned_events.event_id")
675
+ pins = pinned_messages
676
+ .includes(:message, :goals)
677
+ .where("pinned_messages.message_id < ?", first_message_id)
678
+ .order("pinned_messages.message_id")
671
679
 
672
680
  return [] if pins.empty?
673
681
 
674
682
  selected = select_pins_within_budget(pins, budget)
675
683
  return [] if selected.empty?
676
684
 
677
- text = render_pinned_events_section(selected)
678
- [{role: "user", content: "[pinned events]\n#{text}"}]
685
+ text = render_pinned_messages_section(selected)
686
+ [{role: "user", content: "[pinned messages]\n#{text}"}]
679
687
  end
680
688
 
681
- # Walks pinned events chronologically, selecting until the token budget
689
+ # Walks pinned messages chronologically, selecting until the token budget
682
690
  # is exhausted. Always includes at least one pin.
683
691
  #
684
- # @param pins [Array<PinnedEvent>]
692
+ # @param pins [Array<PinnedMessage>]
685
693
  # @param budget [Integer]
686
- # @return [Array<PinnedEvent>]
694
+ # @return [Array<PinnedMessage>]
687
695
  def select_pins_within_budget(pins, budget)
688
696
  selected = []
689
697
  remaining = budget
@@ -699,26 +707,26 @@ class Session < ApplicationRecord
699
707
  selected
700
708
  end
701
709
 
702
- # Renders the pinned events section grouped by Goal.
710
+ # Renders the pinned messages section grouped by Goal.
703
711
  # First Goal referencing a pin shows truncated text; subsequent Goals
704
- # show bare `event N` ID to avoid token-expensive repetition.
712
+ # show bare `message N` ID to avoid token-expensive repetition.
705
713
  #
706
- # @param pins [Array<PinnedEvent>] selected pins with preloaded goals
714
+ # @param pins [Array<PinnedMessage>] selected pins with preloaded goals
707
715
  # @return [String] formatted section text
708
- def render_pinned_events_section(pins)
716
+ def render_pinned_messages_section(pins)
709
717
  goal_pins = group_pins_by_active_goal(pins)
710
718
 
711
- shown_events = Set.new
719
+ shown_messages = Set.new
712
720
  goal_pins.map { |goal, pin_list|
713
- render_goal_pins(goal, pin_list, shown_events)
721
+ render_goal_pins(goal, pin_list, shown_messages)
714
722
  }.join("\n\n")
715
723
  end
716
724
 
717
725
  # Groups pins by their active Goals so the viewport renders
718
726
  # one headed section per Goal.
719
727
  #
720
- # @param pins [Array<PinnedEvent>] pins with preloaded goals
721
- # @return [Hash{Goal => Array<PinnedEvent>}]
728
+ # @param pins [Array<PinnedMessage>] pins with preloaded goals
729
+ # @return [Hash{Goal => Array<PinnedMessage>}]
722
730
  def group_pins_by_active_goal(pins)
723
731
  pairs = pins.flat_map { |pin| active_goal_pin_pairs(pin) }
724
732
  pairs.group_by(&:first).transform_values { |group| group.map(&:last) }
@@ -727,61 +735,61 @@ class Session < ApplicationRecord
727
735
  # Expands a single pin into [goal, pin] pairs for each active Goal
728
736
  # referencing it. Uses in-memory filter on preloaded goals.
729
737
  #
730
- # @param pin [PinnedEvent]
731
- # @return [Array<Array(Goal, PinnedEvent)>]
738
+ # @param pin [PinnedMessage]
739
+ # @return [Array<Array(Goal, PinnedMessage)>]
732
740
  def active_goal_pin_pairs(pin)
733
741
  pin.goals.select(&:active?).map { |goal| [goal, pin] }
734
742
  end
735
743
 
736
- # Renders one Goal's pinned events as a headed list.
744
+ # Renders one Goal's pinned messages as a headed list.
737
745
  #
738
746
  # @param goal [Goal]
739
- # @param pin_list [Array<PinnedEvent>]
740
- # @param shown_events [Set<Integer>] tracks already-rendered event IDs for dedup
747
+ # @param pin_list [Array<PinnedMessage>]
748
+ # @param shown_messages [Set<Integer>] tracks already-rendered message IDs for dedup
741
749
  # @return [String]
742
- def render_goal_pins(goal, pin_list, shown_events)
750
+ def render_goal_pins(goal, pin_list, shown_messages)
743
751
  lines = ["📌 #{goal.description} (id: #{goal.id})"]
744
- pin_list.each { |pin| lines << format_pin_line(pin, shown_events) }
752
+ pin_list.each { |pin| lines << format_pin_line(pin, shown_messages) }
745
753
  lines.join("\n")
746
754
  end
747
755
 
748
756
  # Formats a single pin line with deduplication: first occurrence shows
749
- # truncated text, subsequent occurrences show bare event ID only.
757
+ # truncated text, subsequent occurrences show bare message ID only.
750
758
  #
751
- # @param pin [PinnedEvent]
752
- # @param shown_events [Set<Integer>]
759
+ # @param pin [PinnedMessage]
760
+ # @param shown_messages [Set<Integer>]
753
761
  # @return [String]
754
- def format_pin_line(pin, shown_events)
755
- event_id = pin.event_id
756
- if shown_events.add?(event_id)
757
- " event #{event_id}: #{pin.display_text}"
762
+ def format_pin_line(pin, shown_messages)
763
+ mid = pin.message_id
764
+ if shown_messages.add?(mid)
765
+ " message #{mid}: #{pin.display_text}"
758
766
  else
759
- " event #{event_id}"
767
+ " message #{mid}"
760
768
  end
761
769
  end
762
770
 
763
771
  # Assembles recalled memory messages from passive recall results.
764
- # Recalled events are fetched by ID and formatted as compact snippets
765
- # with session and event context for drill-down via the remember tool.
772
+ # Recalled messages are fetched by ID and formatted as compact snippets
773
+ # with session and message context for drill-down via the remember tool.
766
774
  #
767
775
  # @param budget [Integer] token budget for recall messages
768
776
  # @return [Array<Hash>] Anthropic Messages API format
769
777
  def assemble_recall_messages(budget:)
770
- return [] if recalled_event_ids.blank?
778
+ return [] if recalled_message_ids.blank?
771
779
 
772
- recalled_events = Event.where(id: recalled_event_ids)
780
+ recalled = Message.where(id: recalled_message_ids)
773
781
  .includes(:session)
774
782
  .index_by(&:id)
775
783
 
776
784
  snippets = []
777
785
  remaining = budget
778
786
 
779
- recalled_event_ids.each do |eid|
780
- event = recalled_events[eid]
781
- next unless event
787
+ recalled_message_ids.each do |mid|
788
+ msg = recalled[mid]
789
+ next unless msg
782
790
 
783
- text = format_recall_snippet(event)
784
- cost = [(text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
791
+ text = format_recall_snippet(msg)
792
+ cost = [(text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
785
793
  break if cost > remaining && snippets.any?
786
794
 
787
795
  snippets << text
@@ -793,28 +801,28 @@ class Session < ApplicationRecord
793
801
  [{role: "user", content: "[associative recall]\n#{snippets.join("\n\n")}"}]
794
802
  end
795
803
 
796
- # Formats a recalled event as a compact snippet with enough context
804
+ # Formats a recalled message as a compact snippet with enough context
797
805
  # for the agent to decide whether to drill down with the remember tool.
798
806
  #
799
- # @param event [Event] the recalled event
807
+ # @param msg [Message] the recalled message
800
808
  # @return [String] formatted snippet
801
- def format_recall_snippet(event)
802
- session_label = event.session.name || "session ##{event.session_id}"
803
- content = extract_event_content(event).to_s.truncate(Anima::Settings.recall_max_snippet_tokens * Event::BYTES_PER_TOKEN)
804
- "event #{event.id} (#{session_label}): #{content}"
809
+ def format_recall_snippet(msg)
810
+ session_label = msg.session.name || "session ##{msg.session_id}"
811
+ content = extract_message_content(msg).to_s.truncate(Anima::Settings.recall_max_snippet_tokens * Message::BYTES_PER_TOKEN)
812
+ "message #{msg.id} (#{session_label}): #{content}"
805
813
  end
806
814
 
807
- # Extracts readable content from an event's payload.
815
+ # Extracts readable content from a message's payload.
808
816
  #
809
- # @param event [Event]
817
+ # @param msg [Message]
810
818
  # @return [String]
811
- def extract_event_content(event)
812
- data = event.payload
813
- case event.event_type
819
+ def extract_message_content(msg)
820
+ data = msg.payload
821
+ case msg.message_type
814
822
  when "user_message", "agent_message", "system_message"
815
823
  data["content"]
816
824
  when "tool_call"
817
- if data["tool_name"] == Event::THINK_TOOL
825
+ if data["tool_name"] == Message::THINK_TOOL
818
826
  data.dig("tool_input", "thoughts")
819
827
  else
820
828
  "#{data["tool_name"]}(…)"
@@ -824,39 +832,39 @@ class Session < ApplicationRecord
824
832
  end
825
833
  end
826
834
 
827
- # Converts a chronological list of events into Anthropic wire-format messages.
835
+ # Converts a chronological list of messages into Anthropic wire-format messages.
828
836
  # Prepends a compact timestamp to each user message for LLM time awareness.
829
- # Groups consecutive tool_call events into one assistant message and
830
- # consecutive tool_response events into one user message.
837
+ # Groups consecutive tool_call messages into one assistant message and
838
+ # consecutive tool_response messages into one user message.
831
839
  #
832
- # @param events [Array<Event>]
840
+ # @param msgs [Array<Message>]
833
841
  # @return [Array<Hash>]
834
- def assemble_messages(events)
835
- events.each_with_object([]) do |event, messages|
836
- case event.event_type
842
+ def assemble_messages(msgs)
843
+ msgs.each_with_object([]) do |msg, api_messages|
844
+ case msg.message_type
837
845
  when "user_message"
838
- content = "#{format_event_time(event.timestamp)}\n#{event.payload["content"]}"
839
- messages << {role: "user", content: content}
846
+ content = "#{format_message_time(msg.timestamp)}\n#{msg.payload["content"]}"
847
+ api_messages << {role: "user", content: content}
840
848
  when "agent_message"
841
- messages << {role: "assistant", content: event.payload["content"].to_s}
849
+ api_messages << {role: "assistant", content: msg.payload["content"].to_s}
842
850
  when "tool_call"
843
- append_grouped_block(messages, "assistant", tool_use_block(event.payload))
851
+ append_grouped_block(api_messages, "assistant", tool_use_block(msg.payload))
844
852
  when "tool_response"
845
- append_grouped_block(messages, "user", tool_result_block(event.payload))
853
+ append_grouped_block(api_messages, "user", tool_result_block(msg.payload))
846
854
  when "system_message"
847
855
  # Wrapped as user role with prefix — Claude API has no system role in conversation history
848
- messages << {role: "user", content: "[system] #{event.payload["content"]}"}
856
+ api_messages << {role: "user", content: "[system] #{msg.payload["content"]}"}
849
857
  end
850
858
  end
851
859
  end
852
860
 
853
861
  # Groups consecutive tool blocks into a single message of the given role.
854
- def append_grouped_block(messages, role, block)
855
- prev = messages.last
862
+ def append_grouped_block(api_messages, role, block)
863
+ prev = api_messages.last
856
864
  if prev&.dig(:role) == role && prev[:content].is_a?(Array)
857
865
  prev[:content] << block
858
866
  else
859
- messages << {role: role, content: [block]}
867
+ api_messages << {role: role, content: [block]}
860
868
  end
861
869
  end
862
870
 
@@ -877,23 +885,23 @@ class Session < ApplicationRecord
877
885
  }
878
886
  end
879
887
 
880
- # Formats an event's nanosecond timestamp as a compact time prefix for LLM context.
888
+ # Formats a message's nanosecond timestamp as a compact time prefix for LLM context.
881
889
  # Gives the agent awareness of time of day, day of week, and pauses between messages.
882
890
  #
883
891
  # @param timestamp_ns [Integer] nanoseconds since epoch
884
892
  # @return [String] e.g. "Sat Mar 14 09:51"
885
893
  # @example
886
- # format_event_time(1_710_406_260_000_000_000) #=> "Thu Mar 14 09:51"
887
- def format_event_time(timestamp_ns)
894
+ # format_message_time(1_710_406_260_000_000_000) #=> "Thu Mar 14 09:51"
895
+ def format_message_time(timestamp_ns)
888
896
  Time.at(timestamp_ns / 1_000_000_000.0).strftime("%a %b %-d %H:%M")
889
897
  end
890
898
 
891
- # Delegates to {Event#estimate_tokens} for events not yet counted
899
+ # Delegates to {Message#estimate_tokens} for messages not yet counted
892
900
  # by the background job.
893
901
  #
894
- # @param event [Event]
902
+ # @param msg [Message]
895
903
  # @return [Integer] at least 1
896
- def estimate_tokens(event)
897
- event.estimate_tokens
904
+ def estimate_tokens(msg)
905
+ msg.estimate_tokens
898
906
  end
899
907
  end