anima-core 1.1.3 → 1.3.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +10 -1
  3. data/README.md +36 -11
  4. data/agents/codebase-analyzer.md +2 -2
  5. data/agents/codebase-pattern-finder.md +2 -2
  6. data/agents/documentation-researcher.md +2 -2
  7. data/agents/thoughts-analyzer.md +2 -2
  8. data/agents/web-search-researcher.md +3 -3
  9. data/app/channels/session_channel.rb +83 -64
  10. data/app/decorators/agent_message_decorator.rb +2 -2
  11. data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
  12. data/app/decorators/system_message_decorator.rb +2 -2
  13. data/app/decorators/tool_call_decorator.rb +6 -6
  14. data/app/decorators/tool_decorator.rb +4 -4
  15. data/app/decorators/tool_response_decorator.rb +2 -2
  16. data/app/decorators/user_message_decorator.rb +5 -19
  17. data/app/decorators/web_get_tool_decorator.rb +41 -9
  18. data/app/jobs/agent_request_job.rb +33 -24
  19. data/app/jobs/count_message_tokens_job.rb +39 -0
  20. data/app/jobs/passive_recall_job.rb +4 -4
  21. data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
  22. data/app/models/goal.rb +17 -4
  23. data/app/models/goal_pinned_message.rb +11 -0
  24. data/app/models/message.rb +127 -0
  25. data/app/models/pending_message.rb +43 -0
  26. data/app/models/pinned_message.rb +41 -0
  27. data/app/models/secret.rb +72 -0
  28. data/app/models/session.rb +385 -226
  29. data/app/models/snapshot.rb +25 -25
  30. data/config/environments/test.rb +5 -0
  31. data/config/initializers/time_nanoseconds.rb +11 -0
  32. data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
  33. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  34. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  35. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  36. data/lib/agent_loop.rb +14 -41
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +40 -37
  39. data/lib/analytical_brain/tools/activate_skill.rb +5 -9
  40. data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
  42. data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
  43. data/lib/analytical_brain/tools/finish_goal.rb +5 -8
  44. data/lib/analytical_brain/tools/read_workflow.rb +5 -9
  45. data/lib/analytical_brain/tools/rename_session.rb +3 -10
  46. data/lib/analytical_brain/tools/set_goal.rb +3 -7
  47. data/lib/analytical_brain/tools/update_goal.rb +3 -7
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/installer.rb +7 -1
  51. data/lib/anima/settings.rb +46 -6
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +1 -1
  54. data/lib/credential_store.rb +17 -66
  55. data/lib/events/base.rb +1 -1
  56. data/lib/events/bounce_back.rb +7 -7
  57. data/lib/events/subscribers/persister.rb +15 -22
  58. data/lib/events/subscribers/subagent_message_router.rb +20 -8
  59. data/lib/events/subscribers/transient_broadcaster.rb +2 -2
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +54 -20
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +57 -57
  65. data/lib/mneme/l2_runner.rb +4 -4
  66. data/lib/mneme/passive_recall.rb +2 -2
  67. data/lib/mneme/runner.rb +57 -75
  68. data/lib/mneme/search.rb +38 -38
  69. data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
  70. data/lib/mneme/tools/everything_ok.rb +1 -3
  71. data/lib/mneme/tools/save_snapshot.rb +12 -16
  72. data/lib/shell_session.rb +54 -16
  73. data/lib/tools/base.rb +23 -0
  74. data/lib/tools/bash.rb +60 -16
  75. data/lib/tools/edit.rb +6 -8
  76. data/lib/tools/mark_goal_completed.rb +86 -0
  77. data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
  78. data/lib/tools/read.rb +6 -5
  79. data/lib/tools/recall.rb +98 -0
  80. data/lib/tools/registry.rb +37 -8
  81. data/lib/tools/remember.rb +46 -55
  82. data/lib/tools/response_truncator.rb +70 -0
  83. data/lib/tools/spawn_specialist.rb +15 -25
  84. data/lib/tools/spawn_subagent.rb +14 -22
  85. data/lib/tools/subagent_prompts.rb +42 -6
  86. data/lib/tools/think.rb +26 -10
  87. data/lib/tools/web_get.rb +23 -4
  88. data/lib/tools/write.rb +4 -4
  89. data/lib/tui/app.rb +178 -13
  90. data/lib/tui/braille_spinner.rb +152 -0
  91. data/lib/tui/cable_client.rb +4 -4
  92. data/lib/tui/decorators/base_decorator.rb +17 -8
  93. data/lib/tui/decorators/bash_decorator.rb +2 -2
  94. data/lib/tui/decorators/edit_decorator.rb +5 -4
  95. data/lib/tui/decorators/read_decorator.rb +4 -8
  96. data/lib/tui/decorators/think_decorator.rb +3 -5
  97. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  98. data/lib/tui/decorators/write_decorator.rb +5 -4
  99. data/lib/tui/flash.rb +1 -1
  100. data/lib/tui/formatting.rb +22 -0
  101. data/lib/tui/message_store.rb +103 -59
  102. data/lib/tui/screens/chat.rb +293 -78
  103. data/skills/activerecord/SKILL.md +1 -1
  104. data/skills/dragonruby/SKILL.md +1 -1
  105. data/skills/draper-decorators/SKILL.md +1 -1
  106. data/skills/gh-issue.md +1 -1
  107. data/skills/mcp-server/SKILL.md +1 -1
  108. data/skills/ratatui-ruby/SKILL.md +1 -1
  109. data/skills/rspec/SKILL.md +1 -1
  110. data/templates/config.toml +42 -5
  111. data/templates/soul.md +7 -19
  112. data/workflows/create_handoff.md +1 -1
  113. data/workflows/create_note.md +1 -1
  114. data/workflows/create_plan.md +1 -1
  115. data/workflows/implement_plan.md +1 -1
  116. data/workflows/iterate_plan.md +1 -1
  117. data/workflows/research_codebase.md +1 -1
  118. data/workflows/resume_handoff.md +1 -1
  119. data/workflows/review_pr.md +78 -16
  120. data/workflows/thoughts_init.md +1 -1
  121. data/workflows/validate_plan.md +1 -1
  122. metadata +20 -9
  123. data/app/jobs/count_event_tokens_job.rb +0 -39
  124. data/app/models/event.rb +0 -129
  125. data/app/models/goal_pinned_event.rb +0 -11
  126. data/app/models/pinned_event.rb +0 -41
  127. 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,11 @@ 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
+ has_many :pending_messages, dependent: :destroy
19
20
  has_many :goals, dependent: :destroy
20
21
  has_many :snapshots, dependent: :destroy
21
- has_many :pinned_events, through: :events
22
+ has_many :pinned_messages, through: :messages
22
23
 
23
24
  belongs_to :parent_session, class_name: "Session", optional: true
24
25
  has_many :child_sessions, class_name: "Session", foreign_key: :parent_session_id, dependent: :destroy
@@ -32,6 +33,7 @@ class Session < ApplicationRecord
32
33
 
33
34
  scope :recent, ->(limit = 10) { order(updated_at: :desc).limit(limit) }
34
35
  scope :root_sessions, -> { where(parent_session_id: nil) }
36
+ scope :processing_children_of, ->(parent_id) { where(parent_session_id: parent_id, processing: true) }
35
37
 
36
38
  # Cycles to the next view mode: basic → verbose → debug → basic.
37
39
  #
@@ -46,34 +48,34 @@ class Session < ApplicationRecord
46
48
  parent_session_id.present?
47
49
  end
48
50
 
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,
51
+ # Checks whether the Mneme terminal message has left the viewport and
52
+ # enqueues {MnemeJob} when it has. On the first message of a new session,
51
53
  # initializes the boundary pointer.
52
54
  #
53
- # The terminal event is always a conversation event (user/agent message
55
+ # The terminal message is always a conversation message (user/agent message
54
56
  # or think tool_call), never a bare tool_call/tool_response.
55
57
  #
56
58
  # @return [void]
57
59
  def schedule_mneme!
58
60
  return if sub_agent?
59
61
 
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)
62
+ # Initialize boundary on first conversation message
63
+ if mneme_boundary_message_id.nil?
64
+ first_conversation = messages
65
+ .where(message_type: Message::CONVERSATION_TYPES)
64
66
  .order(:id).first
65
- first_conversation ||= events.deliverable
66
- .where(event_type: "tool_call")
67
- .detect { |e| e.payload["tool_name"] == Event::THINK_TOOL }
67
+ first_conversation ||= messages
68
+ .where(message_type: "tool_call")
69
+ .detect { |msg| msg.payload["tool_name"] == Message::THINK_TOOL }
68
70
 
69
71
  if first_conversation
70
- update_column(:mneme_boundary_event_id, first_conversation.id)
72
+ update_column(:mneme_boundary_message_id, first_conversation.id)
71
73
  end
72
74
  return
73
75
  end
74
76
 
75
- # Check if boundary event has left the viewport
76
- return if viewport_event_ids.include?(mneme_boundary_event_id)
77
+ # Check if boundary message has left the viewport
78
+ return if viewport_message_ids.include?(mneme_boundary_message_id)
77
79
 
78
80
  MnemeJob.perform_later(id)
79
81
  end
@@ -89,7 +91,7 @@ class Session < ApplicationRecord
89
91
  def schedule_analytical_brain!
90
92
  return if sub_agent?
91
93
 
92
- count = events.llm_messages.count
94
+ count = messages.llm_messages.count
93
95
  return if count < 2
94
96
  # Already named — only regenerate at interval boundaries (30, 60, 90, …)
95
97
  return if name.present? && (count % Anima::Settings.name_generation_interval != 0)
@@ -97,43 +99,45 @@ class Session < ApplicationRecord
97
99
  AnalyticalBrainJob.perform_later(id)
98
100
  end
99
101
 
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.
102
+ # Returns the messages currently visible in the LLM context window.
103
+ # Walks messages newest-first and includes them until the token budget
104
+ # is exhausted. Messages are full-size or excluded entirely.
103
105
  #
104
106
  # 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.
107
+ # child messages are prioritized and fill the budget first (newest-first),
108
+ # then parent messages from before the fork point fill the remaining budget.
109
+ # The final array is chronological: parent messages first, then child messages.
110
+ #
111
+ # Pending messages live in a separate table ({PendingMessage}) and never
112
+ # appear in this viewport — they are promoted to real messages before
113
+ # the agent processes them.
108
114
  #
109
115
  # @param token_budget [Integer] maximum tokens to include (positive)
110
- # @param include_pending [Boolean] whether to include pending messages (true for
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) }
116
+ # @return [Array<Message>] chronologically ordered
117
+ def viewport_messages(token_budget: Anima::Settings.token_budget)
118
+ own = select_messages(own_message_scope, budget: token_budget)
119
+ remaining = token_budget - own.sum { |msg| message_token_cost(msg) }
116
120
 
117
121
  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
122
+ parent = select_messages(parent_message_scope, budget: remaining)
123
+ trim_trailing_tool_calls(parent) + own
120
124
  else
121
- own_events
125
+ own
122
126
  end
123
127
  end
124
128
 
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
129
+ # Recalculates the viewport and returns IDs of messages evicted since the
130
+ # last snapshot. Updates the stored viewport_message_ids atomically.
131
+ # Piggybacks on message broadcasts to notify clients which messages left
128
132
  # the LLM's context window.
129
133
  #
130
- # @return [Array<Integer>] IDs of events no longer in the viewport
134
+ # @return [Array<Integer>] IDs of messages no longer in the viewport
131
135
  def recalculate_viewport!
132
- new_ids = viewport_events.map(&:id)
133
- old_ids = viewport_event_ids
136
+ new_ids = viewport_messages.map(&:id)
137
+ old_ids = viewport_message_ids
134
138
 
135
139
  evicted = old_ids - new_ids
136
- update_column(:viewport_event_ids, new_ids) if old_ids != new_ids
140
+ update_column(:viewport_message_ids, new_ids) if old_ids != new_ids
137
141
  evicted
138
142
  end
139
143
 
@@ -142,21 +146,26 @@ class Session < ApplicationRecord
142
146
  # where eviction notifications are unnecessary (clients clear their
143
147
  # store first).
144
148
  #
145
- # @param ids [Array<Integer>] event IDs now in the viewport
149
+ # @param ids [Array<Integer>] message IDs now in the viewport
146
150
  # @return [void]
147
151
  def snapshot_viewport!(ids)
148
- update_column(:viewport_event_ids, ids)
152
+ update_column(:viewport_message_ids, ids)
149
153
  end
150
154
 
151
155
  # Returns the system prompt for this session.
152
- # Sub-agent sessions use their stored prompt. Main sessions assemble
153
- # a system prompt from active skills and current goals.
156
+ # Sub-agent sessions use their stored prompt plus active skills and
157
+ # the pinned task. Main sessions assemble a full system prompt from
158
+ # soul, environment, skills/workflow, and goals.
154
159
  #
155
160
  # @param environment_context [String, nil] pre-assembled environment block
156
161
  # from {EnvironmentProbe}; injected between soul and expertise sections
157
162
  # @return [String, nil] the system prompt text, or nil when nothing to inject
158
163
  def system_prompt(environment_context: nil)
159
- sub_agent? ? prompt : assemble_system_prompt(environment_context: environment_context)
164
+ if sub_agent?
165
+ [prompt, assemble_expertise_section, assemble_task_section].compact.join("\n\n")
166
+ else
167
+ assemble_system_prompt(environment_context: environment_context)
168
+ end
160
169
  end
161
170
 
162
171
  # Activates a skill on this session. Validates the skill exists in the
@@ -217,38 +226,43 @@ class Session < ApplicationRecord
217
226
  save!
218
227
  end
219
228
 
220
- # Assembles the system prompt: soul first, then environment context,
221
- # then skills/workflow, then goals.
229
+ # Assembles the system prompt: version preamble, soul, environment context,
230
+ # skills/workflow, then goals.
222
231
  # The soul is always present — "who am I" before "what can I do."
223
232
  #
224
233
  # @param environment_context [String, nil] pre-assembled environment block
225
234
  # @return [String] composed system prompt
226
235
  def assemble_system_prompt(environment_context: nil)
227
- [assemble_soul_section, environment_context, assemble_expertise_section, assemble_goals_section].compact.join("\n\n")
236
+ [assemble_version_preamble, assemble_soul_section, environment_context, assemble_expertise_section, assemble_goals_section].compact.join("\n\n")
228
237
  end
229
238
 
230
- # Serializes active goals as a lightweight summary for ActionCable
239
+ # Serializes non-evicted goals as a lightweight summary for ActionCable
231
240
  # broadcasts and TUI display. Returns a nested structure: root goals
232
- # with their sub-goals inlined.
241
+ # with their sub-goals inlined. Evicted goals and their sub-goals are
242
+ # excluded.
233
243
  #
234
244
  # @return [Array<Hash>] each with :id, :description, :status, and :sub_goals
235
245
  def goals_summary
236
- goals.root.includes(:sub_goals).order(:created_at).map(&:as_summary)
246
+ goals.root.not_evicted.includes(:sub_goals).order(:created_at).map(&:as_summary)
237
247
  end
238
248
 
239
249
  # Builds the message array expected by the Anthropic Messages API.
240
250
  # Viewport layout (top to bottom):
241
- # [L2 snapshots] [L1 snapshots] [pinned events] [recalled memories] [sliding window events]
251
+ # [L2 snapshots] [L1 snapshots] [pinned messages] [recalled memories] [sliding window messages]
242
252
  #
243
- # Snapshots appear ONLY after their source events have evicted from
253
+ # Snapshots appear ONLY after their source messages have evicted from
244
254
  # the sliding window. L1 snapshots drop once covered by an L2 snapshot.
245
- # Pinned events are critical context attached to active Goals — they
255
+ # Pinned messages are critical context attached to active Goals — they
246
256
  # survive eviction intact until their Goals complete.
247
- # Recalled memories surface relevant older events (passive recall via goals).
257
+ # Recalled memories surface relevant older messages (passive recall via goals).
248
258
  # Each layer has a fixed token budget fraction — snapshots, pins, and recall
249
259
  # consume viewport space, reducing the sliding window size.
250
260
  #
251
- # Sub-agent sessions skip snapshot/pin/recall injection (they inherit parent events directly).
261
+ # The sliding window is post-processed by {#ensure_atomic_tool_pairs}
262
+ # which removes orphaned tool messages whose partner was cut off by the
263
+ # token budget.
264
+ #
265
+ # Sub-agent sessions skip snapshot/pin/recall injection (they inherit parent messages directly).
252
266
  #
253
267
  # @param token_budget [Integer] maximum tokens to include (positive)
254
268
  # @return [Array<Hash>] Anthropic Messages API format
@@ -268,44 +282,44 @@ class Session < ApplicationRecord
268
282
  sliding_budget = token_budget - l2_budget - l1_budget - pinned_budget - recall_budget
269
283
  end
270
284
 
271
- events = viewport_events(token_budget: sliding_budget, include_pending: false)
285
+ window = viewport_messages(token_budget: sliding_budget)
272
286
 
273
287
  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)
288
+ first_message_id = window.first&.id
289
+ snapshot_messages = assemble_snapshot_messages(first_message_id, l2_budget: l2_budget, l1_budget: l1_budget)
290
+ pinned_messages = assemble_pinned_section_messages(first_message_id, budget: pinned_budget)
277
291
  recall_messages = assemble_recall_messages(budget: recall_budget)
278
292
  end
279
293
 
280
- snapshot_messages + pinned_messages + recall_messages + assemble_messages(ensure_atomic_tool_pairs(events))
294
+ snapshot_messages + pinned_messages + recall_messages + assemble_messages(ensure_atomic_tool_pairs(window))
281
295
  end
282
296
 
283
- # Detects orphaned tool_call events (those without a matching tool_response
297
+ # Detects orphaned tool_call messages (those without a matching tool_response
284
298
  # and whose timeout has expired) and creates synthetic error responses.
285
299
  # An orphaned tool_call permanently breaks the session because the
286
300
  # Anthropic API rejects conversations where a tool_use block has no
287
301
  # matching tool_result.
288
302
  #
289
- # Respects the per-call timeout stored in the tool_call event payload —
303
+ # Respects the per-call timeout stored in the tool_call message payload —
290
304
  # a tool_call is only healed after its deadline has passed. This avoids
291
305
  # prematurely healing long-running tools that the agent intentionally
292
306
  # gave an extended timeout.
293
307
  #
294
308
  # @return [Integer] number of synthetic responses created
295
309
  def heal_orphaned_tool_calls!
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")
310
+ current_ns = now_ns
311
+ responded_ids = messages.where(message_type: "tool_response").select(:tool_use_id)
312
+ unresponded = messages.where(message_type: "tool_call")
299
313
  .where.not(tool_use_id: responded_ids)
300
314
 
301
315
  healed = 0
302
316
  unresponded.find_each do |orphan|
303
317
  timeout = orphan.payload["timeout"] || Anima::Settings.tool_timeout
304
318
  deadline_ns = orphan.timestamp + (timeout * 1_000_000_000)
305
- next if now_ns < deadline_ns
319
+ next if current_ns < deadline_ns
306
320
 
307
- events.create!(
308
- event_type: "tool_response",
321
+ messages.create!(
322
+ message_type: "tool_response",
309
323
  payload: {
310
324
  "type" => "tool_response",
311
325
  "content" => "Tool execution timed out after #{timeout} seconds — no result was returned.",
@@ -314,7 +328,7 @@ class Session < ApplicationRecord
314
328
  "success" => false
315
329
  },
316
330
  tool_use_id: orphan.tool_use_id,
317
- timestamp: now_ns
331
+ timestamp: current_ns
318
332
  )
319
333
  healed += 1
320
334
  end
@@ -323,57 +337,58 @@ class Session < ApplicationRecord
323
337
 
324
338
  # Delivers a user message respecting the session's processing state.
325
339
  #
326
- # When idle, persists the event directly and enqueues {AgentRequestJob}
327
- # to process it. When mid-turn ({#processing?}), emits a pending
328
- # {Events::UserMessage} via {Events::Bus} so it queues until the
329
- # current agent loop completes preventing interleaving between
330
- # tool_use/tool_result pairs.
340
+ # When idle, persists the message directly and enqueues {AgentRequestJob}
341
+ # to process it. When mid-turn ({#processing?}), stages the message as
342
+ # a {PendingMessage} in a separate table — it gets no message ID until
343
+ # promoted, so it can never interleave with tool_call/tool_response pairs.
331
344
  #
332
345
  # @param content [String] user message text
333
- # @param bounce_back [Boolean] when true, passes +event_id+ to the job
346
+ # @param bounce_back [Boolean] when true, passes +message_id+ to the job
334
347
  # so failed LLM delivery triggers a {Events::BounceBack} (used by
335
348
  # {SessionChannel#speak} for immediate-display messages)
336
349
  # @return [void]
337
350
  def enqueue_user_message(content, bounce_back: false)
338
351
  if processing?
339
- Events::Bus.emit(Events::UserMessage.new(
340
- content: content, session_id: id,
341
- status: Event::PENDING_STATUS
342
- ))
352
+ pending_messages.create!(content: content)
343
353
  else
344
- event = create_user_event(content)
345
- job_args = bounce_back ? {event_id: event.id} : {}
354
+ msg = create_user_message(content)
355
+ job_args = bounce_back ? {message_id: msg.id} : {}
346
356
  AgentRequestJob.perform_later(id, **job_args)
347
357
  end
348
358
  end
349
359
 
350
- # Persists a user message event directly, bypassing the pending queue.
360
+ # Persists a user message directly, bypassing the pending queue.
351
361
  #
352
- # Used by {#enqueue_user_message} (idle path), {AgentLoop#process},
362
+ # Used by {#enqueue_user_message} (idle path), {AgentLoop#run},
353
363
  # and sub-agent spawn tools ({Tools::SpawnSubagent}, {Tools::SpawnSpecialist})
354
364
  # because the global {Events::Subscribers::Persister} skips non-pending user
355
365
  # messages — these callers own the persistence lifecycle.
356
366
  #
357
367
  # @param content [String] user message text
358
- # @return [Event] the persisted event record
359
- def create_user_event(content)
360
- now = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
361
- events.create!(
362
- event_type: "user_message",
368
+ # @return [Message] the persisted message record
369
+ def create_user_message(content)
370
+ now = now_ns
371
+ messages.create!(
372
+ message_type: "user_message",
363
373
  payload: {type: "user_message", content: content, session_id: id, timestamp: now},
364
374
  timestamp: now
365
375
  )
366
376
  end
367
377
 
368
- # Promotes all pending user messages to delivered status so they
369
- # appear in the next LLM context. Triggers broadcast_update for
370
- # each event so connected clients refresh the pending indicator.
378
+ # Promotes all pending messages into the conversation history.
379
+ # Each {PendingMessage} is atomically deleted and replaced with a real
380
+ # {Message} the new message gets the next auto-increment ID,
381
+ # naturally placing it after any tool_call/tool_response pairs that
382
+ # were persisted while the message was waiting.
371
383
  #
372
384
  # @return [Integer] number of promoted messages
373
385
  def promote_pending_messages!
374
386
  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"))
387
+ pending_messages.find_each do |pm|
388
+ transaction do
389
+ create_user_message(pm.content)
390
+ pm.destroy!
391
+ end
377
392
  promoted += 1
378
393
  end
379
394
  promoted
@@ -396,12 +411,112 @@ class Session < ApplicationRecord
396
411
  ActionCable.server.broadcast("session_#{parent_session_id}", {
397
412
  "action" => "children_updated",
398
413
  "session_id" => parent_session_id,
399
- "children" => children.map { |child| {"id" => child.id, "name" => child.name, "processing" => child.processing?} }
414
+ "children" => children.map { |child|
415
+ state = child.processing? ? "llm_generating" : "idle"
416
+ {"id" => child.id, "name" => child.name, "processing" => child.processing?, "session_state" => state}
417
+ }
400
418
  })
401
419
  end
402
420
 
421
+ # Broadcasts the session's current processing state to all subscribed
422
+ # clients. Stateless — no storage, pure broadcast. The TUI uses this to
423
+ # drive the braille spinner animation and sub-agent HUD icons.
424
+ #
425
+ # Payload broadcast to +session_{id}+:
426
+ # {"action" => "session_state", "state" => state, "session_id" => id}
427
+ # # plus "tool" key when state is "tool_executing"
428
+ #
429
+ # For sub-agents, also broadcasts +child_state+ to the parent stream:
430
+ # {"action" => "child_state", "state" => state, "session_id" => id, "child_id" => id}
431
+ #
432
+ # @param state [String] one of "idle", "llm_generating", "tool_executing", "interrupting"
433
+ # @param tool [String, nil] tool name when state is "tool_executing"
434
+ # @return [void]
435
+ def broadcast_session_state(state, tool: nil)
436
+ payload = {"action" => "session_state", "state" => state, "session_id" => id}
437
+ payload["tool"] = tool if tool
438
+ ActionCable.server.broadcast("session_#{id}", payload)
439
+
440
+ # Notify the parent's stream so the HUD updates child state icons
441
+ # without requiring a full children_updated query.
442
+ return unless parent_session_id
443
+
444
+ parent_payload = payload.merge("action" => "child_state", "child_id" => id)
445
+ ActionCable.server.broadcast("session_#{parent_session_id}", parent_payload)
446
+ end
447
+
448
+ # Broadcasts the full LLM debug context to debug-mode TUI clients.
449
+ # Called on every LLM request so the TUI shows exactly what the LLM
450
+ # receives — system prompt and tool schemas. No-op outside debug mode.
451
+ #
452
+ # @param system [String, nil] the final system prompt sent to the LLM
453
+ # @param tools [Array<Hash>, nil] tool schemas sent to the LLM
454
+ # @return [void]
455
+ def broadcast_debug_context(system:, tools: nil)
456
+ return unless view_mode == "debug" && system
457
+
458
+ ActionCable.server.broadcast("session_#{id}", self.class.system_prompt_payload(system, tools: tools))
459
+ end
460
+
461
+ # Returns the deterministic tool schemas for this session's type and
462
+ # granted_tools configuration. Standard and spawn tools are static
463
+ # class-level definitions — no ShellSession or registry needed.
464
+ # MCP tools are excluded (they require live server queries and appear
465
+ # after the first LLM request via {#broadcast_debug_context}).
466
+ #
467
+ # @return [Array<Hash>] tool schema hashes matching Anthropic tools API format
468
+ def tool_schemas
469
+ tools = if granted_tools
470
+ granted = granted_tools.filter_map { |name| AgentLoop::STANDARD_TOOLS_BY_NAME[name] }
471
+ (AgentLoop::ALWAYS_GRANTED_TOOLS + granted).uniq
472
+ else
473
+ AgentLoop::STANDARD_TOOLS.dup
474
+ end
475
+
476
+ unless sub_agent?
477
+ tools.push(Tools::SpawnSubagent, Tools::SpawnSpecialist, Tools::OpenIssue)
478
+ end
479
+
480
+ if sub_agent?
481
+ tools.push(Tools::MarkGoalCompleted)
482
+ end
483
+
484
+ tools.map(&:schema)
485
+ end
486
+
487
+ # Builds the system prompt payload for debug mode transmission.
488
+ # Token estimate covers both the system prompt and tool schemas
489
+ # since both consume the LLM's context window.
490
+ # Tools are sent as raw schemas; the TUI formats them as TOON for display.
491
+ #
492
+ # @param prompt [String] system prompt text
493
+ # @param tools [Array<Hash>, nil] tool schemas
494
+ # @return [Hash] payload with type, rendered debug content, and token estimate
495
+ def self.system_prompt_payload(prompt, tools: nil)
496
+ total_bytes = prompt.bytesize
497
+ total_bytes += tools.to_json.bytesize if tools&.any?
498
+ tokens = Message.estimate_token_count(total_bytes)
499
+
500
+ debug = {role: :system_prompt, content: prompt, tokens: tokens, estimated: true}
501
+ debug[:tools] = tools if tools&.any?
502
+
503
+ {
504
+ "id" => Message::SYSTEM_PROMPT_ID,
505
+ "type" => "system_prompt",
506
+ "rendered" => {"debug" => debug}
507
+ }
508
+ end
509
+
403
510
  private
404
511
 
512
+ # One-line version preamble so the agent knows its own version.
513
+ # Useful for commits, handoffs, and debugging.
514
+ #
515
+ # @return [String] e.g. "You are running on Anima v1.1.3"
516
+ def assemble_version_preamble
517
+ "You are running on Anima v#{Anima::VERSION}"
518
+ end
519
+
405
520
  # Reads the soul file — the agent's self-authored identity.
406
521
  # Loaded as the first section of every system prompt, before skills,
407
522
  # workflows, and goals.
@@ -438,17 +553,55 @@ class Session < ApplicationRecord
438
553
  "## Your Expertise\n\nYou know this deeply. Now's your chance to put it to work.\n\n#{sections.join("\n\n")}"
439
554
  end
440
555
 
556
+ # Evicts completed goals that have aged past the configured threshold
557
+ # of meaningful messages (user + agent turns). Pure arithmetic — no LLM
558
+ # involvement. Called before prompt assembly so evicted goals are
559
+ # excluded from the very next context window.
560
+ #
561
+ # @return [void]
562
+ def evict_stale_goals!
563
+ threshold = Anima::Settings.completed_decay_messages
564
+ goals.evictable.each do |goal|
565
+ messages_since = messages.llm_messages.where("created_at > ?", goal.completed_at).count
566
+ goal.update!(evicted_at: Time.current) if messages_since >= threshold
567
+ end
568
+ end
569
+
441
570
  # Assembles the goals section of the system prompt.
571
+ # Automatically evicts stale completed goals before filtering.
442
572
  # Active root goals render as `###` headings with sub-goal checkboxes.
443
573
  # Completed root goals collapse to a single strikethrough line.
574
+ # Evicted goals are excluded entirely to free context budget.
444
575
  #
445
576
  # @return [String, nil] goals section, or nil when no goals exist
446
577
  def assemble_goals_section
447
- root_goals = goals.root.includes(:sub_goals).order(:created_at)
578
+ evict_stale_goals!
579
+
580
+ root_goals = goals.root.not_evicted.includes(:sub_goals).order(:created_at)
448
581
  return if root_goals.empty?
449
582
 
450
583
  entries = root_goals.map { |goal| render_goal_markdown(goal) }
451
- "## Current Goals\n\n#{entries.join("\n\n")}"
584
+ "Current Goals\n=============\n\n#{entries.join("\n\n")}"
585
+ end
586
+
587
+ # Assembles the task section for sub-agent system prompts.
588
+ # Sub-agents have a single pinned goal — their entire raison d'etre.
589
+ # Rendered as a persistent task block so the LLM always knows what it
590
+ # was spawned to do, regardless of conversation length.
591
+ #
592
+ # @return [String, nil] task section, or nil when no active goal exists
593
+ def assemble_task_section
594
+ goal = goals.active.root.first
595
+ return unless goal
596
+
597
+ <<~SECTION.strip
598
+ Your Task
599
+ =========
600
+
601
+ #{goal.description}
602
+
603
+ Complete this task and call mark_goal_completed when done.
604
+ SECTION
452
605
  end
453
606
 
454
607
  # Renders a single root goal with its sub-goals as Markdown.
@@ -520,40 +673,38 @@ class Session < ApplicationRecord
520
673
  })
521
674
  end
522
675
 
523
- # Scopes own events for viewport assembly.
676
+ # Scopes own messages for viewport assembly.
524
677
  # @return [ActiveRecord::Relation]
525
- def own_event_scope(include_pending)
526
- scope = events.context_events
527
- include_pending ? scope : scope.deliverable
678
+ def own_message_scope
679
+ messages.context_messages
528
680
  end
529
681
 
530
- # Scopes parent events created before this session's fork point.
531
- # Excludes spawn tool events — sub-agents don't need to see sibling
682
+ # Scopes parent messages created before this session's fork point.
683
+ # Excludes spawn tool messages — sub-agents don't need to see sibling
532
684
  # spawn pairs, which cause role confusion (the sub-agent mistakes
533
685
  # itself for the parent when it sees "Specialist @sibling spawned...").
534
686
  # @return [ActiveRecord::Relation]
535
- def parent_event_scope(include_pending)
536
- scope = parent_session.events.context_events
537
- .excluding_spawn_events
687
+ def parent_message_scope
688
+ parent_session.messages.context_messages
689
+ .excluding_spawn_messages
538
690
  .where(created_at: ...created_at)
539
- include_pending ? scope : scope.deliverable
540
691
  end
541
692
 
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.
693
+ # Walks messages newest-first, selecting until the token budget is exhausted.
694
+ # Always includes at least the newest message even if it exceeds budget.
544
695
  #
545
- # @param scope [ActiveRecord::Relation] event scope to select from
696
+ # @param scope [ActiveRecord::Relation] message scope to select from
546
697
  # @param budget [Integer] maximum tokens to include
547
- # @return [Array<Event>] chronologically ordered
548
- def select_events(scope, budget:)
698
+ # @return [Array<Message>] chronologically ordered
699
+ def select_messages(scope, budget:)
549
700
  selected = []
550
701
  remaining = budget
551
702
 
552
- scope.reorder(id: :desc).each do |event|
553
- cost = event_token_cost(event)
703
+ scope.reorder(id: :desc).each do |msg|
704
+ cost = message_token_cost(msg)
554
705
  break if cost > remaining && selected.any?
555
706
 
556
- selected << event
707
+ selected << msg
557
708
  remaining -= cost
558
709
  end
559
710
 
@@ -561,59 +712,59 @@ class Session < ApplicationRecord
561
712
  end
562
713
 
563
714
  # @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)
715
+ def message_token_cost(msg)
716
+ (msg.token_count > 0) ? msg.token_count : estimate_tokens(msg)
566
717
  end
567
718
 
568
- # Removes trailing tool_call events that lack matching tool_response.
719
+ # Removes trailing tool_call messages that lack matching tool_response.
569
720
  # Prevents orphaned tool_use blocks at the parent/child viewport boundary
570
721
  # (the spawn_subagent/spawn_specialist tool_call is emitted before the child exists,
571
722
  # 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
723
+ def trim_trailing_tool_calls(message_list)
724
+ message_list.pop while message_list.last&.message_type == "tool_call"
725
+ message_list
575
726
  end
576
727
 
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
728
+ # Ensures every tool_call in the message list has a matching tool_response
729
+ # (and vice versa) by removing unpaired messages. The Anthropic API requires
579
730
  # every tool_use block to have a tool_result — a missing partner causes
580
731
  # a permanent API error. Token budget cutoffs can split pairs when the
581
732
  # boundary falls between a tool_call and its tool_response.
582
733
  #
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
734
+ # @param message_list [Array<Message>] chronologically ordered messages
735
+ # @return [Array<Message>] messages with unpaired tool messages removed
736
+ def ensure_atomic_tool_pairs(message_list)
737
+ tool_msgs = message_list.select { |m| m.tool_use_id.present? }
738
+ return message_list if tool_msgs.empty?
739
+
740
+ paired = tool_msgs.group_by(&:tool_use_id)
741
+ complete_ids = paired.each_with_object(Set.new) do |(uid, msgs), set|
742
+ has_call = msgs.any? { |m| m.message_type == "tool_call" }
743
+ has_response = msgs.any? { |m| m.message_type == "tool_response" }
744
+ set << uid if has_call && has_response
594
745
  end
595
746
 
596
- event_list.reject { |e| e.tool_use_id.present? && !complete_ids.include?(e.tool_use_id) }
747
+ message_list.reject { |m| m.tool_use_id.present? && !complete_ids.include?(m.tool_use_id) }
597
748
  end
598
749
 
599
750
  # Selects visible snapshots and formats them as Anthropic messages.
600
- # Snapshots are visible when their source events have fully evicted.
751
+ # Snapshots are visible when their source messages have fully evicted.
601
752
  # L1 snapshots are excluded when covered by an L2 snapshot.
602
753
  #
603
- # @param first_event_id [Integer, nil] first event ID in the sliding window
754
+ # @param first_message_id [Integer, nil] first message ID in the sliding window
604
755
  # @param l2_budget [Integer] token budget for L2 snapshots
605
756
  # @param l1_budget [Integer] token budget for L1 snapshots
606
757
  # @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
758
+ def assemble_snapshot_messages(first_message_id, l2_budget:, l1_budget:)
759
+ return [] unless first_message_id
609
760
 
610
761
  l2_messages = select_snapshots_within_budget(
611
- snapshots.for_level(2).source_events_evicted(first_event_id).chronological,
762
+ snapshots.for_level(2).source_messages_evicted(first_message_id).chronological,
612
763
  budget: l2_budget
613
764
  ).map { |snapshot| format_snapshot_message(snapshot, label: "long-term memory") }
614
765
 
615
766
  l1_messages = select_snapshots_within_budget(
616
- snapshots.for_level(1).not_covered_by_l2.source_events_evicted(first_event_id).chronological,
767
+ snapshots.for_level(1).not_covered_by_l2.source_messages_evicted(first_message_id).chronological,
617
768
  budget: l1_budget
618
769
  ).map { |snapshot| format_snapshot_message(snapshot, label: "recent memory") }
619
770
 
@@ -651,39 +802,39 @@ class Session < ApplicationRecord
651
802
  {role: "user", content: "[#{label}]\n#{snapshot.text}"}
652
803
  end
653
804
 
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).
805
+ # Assembles pinned messages as a Goals section message for the viewport.
806
+ # Only includes pinned messages whose source message has evicted from the
807
+ # sliding window (same rule as snapshots — no duplication with live messages).
657
808
  #
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.
809
+ # Deduplication: the first Goal referencing a message shows its truncated
810
+ # display_text; subsequent Goals show a bare `message N` ID to save tokens.
660
811
  #
661
- # @param first_event_id [Integer, nil] first event ID in the sliding window
662
- # @param budget [Integer] token budget for pinned events
812
+ # @param first_message_id [Integer, nil] first message ID in the sliding window
813
+ # @param budget [Integer] token budget for pinned messages
663
814
  # @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
815
+ def assemble_pinned_section_messages(first_message_id, budget:)
816
+ return [] unless first_message_id
666
817
 
667
- pins = pinned_events
668
- .includes(:event, :goals)
669
- .where("pinned_events.event_id < ?", first_event_id)
670
- .order("pinned_events.event_id")
818
+ pins = pinned_messages
819
+ .includes(:message, :goals)
820
+ .where("pinned_messages.message_id < ?", first_message_id)
821
+ .order("pinned_messages.message_id")
671
822
 
672
823
  return [] if pins.empty?
673
824
 
674
825
  selected = select_pins_within_budget(pins, budget)
675
826
  return [] if selected.empty?
676
827
 
677
- text = render_pinned_events_section(selected)
678
- [{role: "user", content: "[pinned events]\n#{text}"}]
828
+ text = render_pinned_messages_section(selected)
829
+ [{role: "user", content: "[pinned messages]\n#{text}"}]
679
830
  end
680
831
 
681
- # Walks pinned events chronologically, selecting until the token budget
832
+ # Walks pinned messages chronologically, selecting until the token budget
682
833
  # is exhausted. Always includes at least one pin.
683
834
  #
684
- # @param pins [Array<PinnedEvent>]
835
+ # @param pins [Array<PinnedMessage>]
685
836
  # @param budget [Integer]
686
- # @return [Array<PinnedEvent>]
837
+ # @return [Array<PinnedMessage>]
687
838
  def select_pins_within_budget(pins, budget)
688
839
  selected = []
689
840
  remaining = budget
@@ -699,26 +850,26 @@ class Session < ApplicationRecord
699
850
  selected
700
851
  end
701
852
 
702
- # Renders the pinned events section grouped by Goal.
853
+ # Renders the pinned messages section grouped by Goal.
703
854
  # First Goal referencing a pin shows truncated text; subsequent Goals
704
- # show bare `event N` ID to avoid token-expensive repetition.
855
+ # show bare `message N` ID to avoid token-expensive repetition.
705
856
  #
706
- # @param pins [Array<PinnedEvent>] selected pins with preloaded goals
857
+ # @param pins [Array<PinnedMessage>] selected pins with preloaded goals
707
858
  # @return [String] formatted section text
708
- def render_pinned_events_section(pins)
859
+ def render_pinned_messages_section(pins)
709
860
  goal_pins = group_pins_by_active_goal(pins)
710
861
 
711
- shown_events = Set.new
862
+ shown_messages = Set.new
712
863
  goal_pins.map { |goal, pin_list|
713
- render_goal_pins(goal, pin_list, shown_events)
864
+ render_goal_pins(goal, pin_list, shown_messages)
714
865
  }.join("\n\n")
715
866
  end
716
867
 
717
868
  # Groups pins by their active Goals so the viewport renders
718
869
  # one headed section per Goal.
719
870
  #
720
- # @param pins [Array<PinnedEvent>] pins with preloaded goals
721
- # @return [Hash{Goal => Array<PinnedEvent>}]
871
+ # @param pins [Array<PinnedMessage>] pins with preloaded goals
872
+ # @return [Hash{Goal => Array<PinnedMessage>}]
722
873
  def group_pins_by_active_goal(pins)
723
874
  pairs = pins.flat_map { |pin| active_goal_pin_pairs(pin) }
724
875
  pairs.group_by(&:first).transform_values { |group| group.map(&:last) }
@@ -727,61 +878,61 @@ class Session < ApplicationRecord
727
878
  # Expands a single pin into [goal, pin] pairs for each active Goal
728
879
  # referencing it. Uses in-memory filter on preloaded goals.
729
880
  #
730
- # @param pin [PinnedEvent]
731
- # @return [Array<Array(Goal, PinnedEvent)>]
881
+ # @param pin [PinnedMessage]
882
+ # @return [Array<Array(Goal, PinnedMessage)>]
732
883
  def active_goal_pin_pairs(pin)
733
884
  pin.goals.select(&:active?).map { |goal| [goal, pin] }
734
885
  end
735
886
 
736
- # Renders one Goal's pinned events as a headed list.
887
+ # Renders one Goal's pinned messages as a headed list.
737
888
  #
738
889
  # @param goal [Goal]
739
- # @param pin_list [Array<PinnedEvent>]
740
- # @param shown_events [Set<Integer>] tracks already-rendered event IDs for dedup
890
+ # @param pin_list [Array<PinnedMessage>]
891
+ # @param shown_messages [Set<Integer>] tracks already-rendered message IDs for dedup
741
892
  # @return [String]
742
- def render_goal_pins(goal, pin_list, shown_events)
893
+ def render_goal_pins(goal, pin_list, shown_messages)
743
894
  lines = ["📌 #{goal.description} (id: #{goal.id})"]
744
- pin_list.each { |pin| lines << format_pin_line(pin, shown_events) }
895
+ pin_list.each { |pin| lines << format_pin_line(pin, shown_messages) }
745
896
  lines.join("\n")
746
897
  end
747
898
 
748
899
  # Formats a single pin line with deduplication: first occurrence shows
749
- # truncated text, subsequent occurrences show bare event ID only.
900
+ # truncated text, subsequent occurrences show bare message ID only.
750
901
  #
751
- # @param pin [PinnedEvent]
752
- # @param shown_events [Set<Integer>]
902
+ # @param pin [PinnedMessage]
903
+ # @param shown_messages [Set<Integer>]
753
904
  # @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}"
905
+ def format_pin_line(pin, shown_messages)
906
+ mid = pin.message_id
907
+ if shown_messages.add?(mid)
908
+ " message #{mid}: #{pin.display_text}"
758
909
  else
759
- " event #{event_id}"
910
+ " message #{mid}"
760
911
  end
761
912
  end
762
913
 
763
914
  # 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.
915
+ # Recalled messages are fetched by ID and formatted as compact snippets
916
+ # with session and message context for drill-down via the remember tool.
766
917
  #
767
918
  # @param budget [Integer] token budget for recall messages
768
919
  # @return [Array<Hash>] Anthropic Messages API format
769
920
  def assemble_recall_messages(budget:)
770
- return [] if recalled_event_ids.blank?
921
+ return [] if recalled_message_ids.blank?
771
922
 
772
- recalled_events = Event.where(id: recalled_event_ids)
923
+ recalled = Message.where(id: recalled_message_ids)
773
924
  .includes(:session)
774
925
  .index_by(&:id)
775
926
 
776
927
  snippets = []
777
928
  remaining = budget
778
929
 
779
- recalled_event_ids.each do |eid|
780
- event = recalled_events[eid]
781
- next unless event
930
+ recalled_message_ids.each do |mid|
931
+ msg = recalled[mid]
932
+ next unless msg
782
933
 
783
- text = format_recall_snippet(event)
784
- cost = [(text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
934
+ text = format_recall_snippet(msg)
935
+ cost = Message.estimate_token_count(text.bytesize)
785
936
  break if cost > remaining && snippets.any?
786
937
 
787
938
  snippets << text
@@ -793,28 +944,28 @@ class Session < ApplicationRecord
793
944
  [{role: "user", content: "[associative recall]\n#{snippets.join("\n\n")}"}]
794
945
  end
795
946
 
796
- # Formats a recalled event as a compact snippet with enough context
947
+ # Formats a recalled message as a compact snippet with enough context
797
948
  # for the agent to decide whether to drill down with the remember tool.
798
949
  #
799
- # @param event [Event] the recalled event
950
+ # @param msg [Message] the recalled message
800
951
  # @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}"
952
+ def format_recall_snippet(msg)
953
+ session_label = msg.session.name || "session ##{msg.session_id}"
954
+ content = extract_message_content(msg).to_s.truncate(Anima::Settings.recall_max_snippet_tokens * Message::BYTES_PER_TOKEN)
955
+ "message #{msg.id} (#{session_label}): #{content}"
805
956
  end
806
957
 
807
- # Extracts readable content from an event's payload.
958
+ # Extracts readable content from a message's payload.
808
959
  #
809
- # @param event [Event]
960
+ # @param msg [Message]
810
961
  # @return [String]
811
- def extract_event_content(event)
812
- data = event.payload
813
- case event.event_type
962
+ def extract_message_content(msg)
963
+ data = msg.payload
964
+ case msg.message_type
814
965
  when "user_message", "agent_message", "system_message"
815
966
  data["content"]
816
967
  when "tool_call"
817
- if data["tool_name"] == Event::THINK_TOOL
968
+ if data["tool_name"] == Message::THINK_TOOL
818
969
  data.dig("tool_input", "thoughts")
819
970
  else
820
971
  "#{data["tool_name"]}(…)"
@@ -824,39 +975,39 @@ class Session < ApplicationRecord
824
975
  end
825
976
  end
826
977
 
827
- # Converts a chronological list of events into Anthropic wire-format messages.
978
+ # Converts a chronological list of messages into Anthropic wire-format messages.
828
979
  # 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.
980
+ # Groups consecutive tool_call messages into one assistant message and
981
+ # consecutive tool_response messages into one user message.
831
982
  #
832
- # @param events [Array<Event>]
983
+ # @param msgs [Array<Message>]
833
984
  # @return [Array<Hash>]
834
- def assemble_messages(events)
835
- events.each_with_object([]) do |event, messages|
836
- case event.event_type
985
+ def assemble_messages(msgs)
986
+ msgs.each_with_object([]) do |msg, api_messages|
987
+ case msg.message_type
837
988
  when "user_message"
838
- content = "#{format_event_time(event.timestamp)}\n#{event.payload["content"]}"
839
- messages << {role: "user", content: content}
989
+ content = "#{format_message_time(msg.timestamp)}\n#{msg.payload["content"]}"
990
+ api_messages << {role: "user", content: content}
840
991
  when "agent_message"
841
- messages << {role: "assistant", content: event.payload["content"].to_s}
992
+ api_messages << {role: "assistant", content: msg.payload["content"].to_s}
842
993
  when "tool_call"
843
- append_grouped_block(messages, "assistant", tool_use_block(event.payload))
994
+ append_grouped_block(api_messages, "assistant", tool_use_block(msg.payload))
844
995
  when "tool_response"
845
- append_grouped_block(messages, "user", tool_result_block(event.payload))
996
+ append_grouped_block(api_messages, "user", tool_result_block(msg.payload))
846
997
  when "system_message"
847
998
  # Wrapped as user role with prefix — Claude API has no system role in conversation history
848
- messages << {role: "user", content: "[system] #{event.payload["content"]}"}
999
+ api_messages << {role: "user", content: "[system] #{msg.payload["content"]}"}
849
1000
  end
850
1001
  end
851
1002
  end
852
1003
 
853
1004
  # Groups consecutive tool blocks into a single message of the given role.
854
- def append_grouped_block(messages, role, block)
855
- prev = messages.last
1005
+ def append_grouped_block(api_messages, role, block)
1006
+ prev = api_messages.last
856
1007
  if prev&.dig(:role) == role && prev[:content].is_a?(Array)
857
1008
  prev[:content] << block
858
1009
  else
859
- messages << {role: role, content: [block]}
1010
+ api_messages << {role: role, content: [block]}
860
1011
  end
861
1012
  end
862
1013
 
@@ -877,23 +1028,31 @@ class Session < ApplicationRecord
877
1028
  }
878
1029
  end
879
1030
 
880
- # Formats an event's nanosecond timestamp as a compact time prefix for LLM context.
1031
+ # Formats a message's nanosecond timestamp as a compact time prefix for LLM context.
881
1032
  # Gives the agent awareness of time of day, day of week, and pauses between messages.
882
1033
  #
883
1034
  # @param timestamp_ns [Integer] nanoseconds since epoch
884
1035
  # @return [String] e.g. "Sat Mar 14 09:51"
885
1036
  # @example
886
- # format_event_time(1_710_406_260_000_000_000) #=> "Thu Mar 14 09:51"
887
- def format_event_time(timestamp_ns)
888
- Time.at(timestamp_ns / 1_000_000_000.0).strftime("%a %b %-d %H:%M")
1037
+ # format_message_time(1_710_406_260_000_000_000) #=> "Thu Mar 14 09:51"
1038
+ def format_message_time(timestamp_ns)
1039
+ Time.at(timestamp_ns / 1_000_000_000.0).utc.strftime("%a %b %-d %H:%M")
1040
+ end
1041
+
1042
+ # Current time as nanoseconds since epoch. Uses Time.current so
1043
+ # ActiveSupport's freeze_time works in tests.
1044
+ #
1045
+ # @return [Integer] nanoseconds since epoch
1046
+ def now_ns
1047
+ Time.current.to_ns
889
1048
  end
890
1049
 
891
- # Delegates to {Event#estimate_tokens} for events not yet counted
1050
+ # Delegates to {Message#estimate_tokens} for messages not yet counted
892
1051
  # by the background job.
893
1052
  #
894
- # @param event [Event]
1053
+ # @param msg [Message]
895
1054
  # @return [Integer] at least 1
896
- def estimate_tokens(event)
897
- event.estimate_tokens
1055
+ def estimate_tokens(msg)
1056
+ msg.estimate_tokens
898
1057
  end
899
1058
  end