anima-core 1.2.0 → 1.4.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +14 -8
  3. data/README.md +96 -23
  4. data/agents/codebase-analyzer.md +1 -1
  5. data/agents/codebase-pattern-finder.md +1 -1
  6. data/agents/documentation-researcher.md +1 -1
  7. data/agents/thoughts-analyzer.md +1 -1
  8. data/agents/web-search-researcher.md +2 -2
  9. data/app/channels/session_channel.rb +53 -35
  10. data/app/decorators/tool_call_decorator.rb +7 -7
  11. data/app/decorators/user_message_decorator.rb +3 -17
  12. data/app/jobs/agent_request_job.rb +15 -6
  13. data/app/jobs/passive_recall_job.rb +6 -11
  14. data/app/models/concerns/message/broadcasting.rb +1 -0
  15. data/app/models/goal.rb +14 -0
  16. data/app/models/message.rb +13 -31
  17. data/app/models/pending_message.rb +191 -0
  18. data/app/models/secret.rb +72 -0
  19. data/app/models/session.rb +480 -271
  20. data/bin/inspect-cassette +144 -0
  21. data/bin/release +212 -0
  22. data/bin/with-llms +20 -0
  23. data/config/database.yml +1 -0
  24. data/config/environments/test.rb +5 -0
  25. data/config/initializers/time_nanoseconds.rb +11 -0
  26. data/db/cable_structure.sql +9 -0
  27. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  28. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  29. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  30. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  31. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  32. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  33. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  34. data/db/queue_structure.sql +61 -0
  35. data/db/structure.sql +120 -0
  36. data/lib/agent_loop.rb +53 -51
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +19 -6
  39. data/lib/analytical_brain/tools/activate_skill.rb +2 -2
  40. data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
  42. data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
  43. data/lib/analytical_brain/tools/finish_goal.rb +3 -0
  44. data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
  45. data/lib/analytical_brain/tools/read_workflow.rb +2 -2
  46. data/lib/analytical_brain/tools/set_goal.rb +5 -1
  47. data/lib/analytical_brain/tools/update_goal.rb +5 -1
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/cli.rb +41 -13
  51. data/lib/anima/installer.rb +20 -1
  52. data/lib/anima/settings.rb +37 -2
  53. data/lib/anima/version.rb +1 -1
  54. data/lib/anima.rb +1 -1
  55. data/lib/credential_store.rb +17 -66
  56. data/lib/events/agent_message.rb +14 -0
  57. data/lib/events/base.rb +1 -1
  58. data/lib/events/subscribers/persister.rb +12 -18
  59. data/lib/events/subscribers/subagent_message_router.rb +18 -9
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +91 -50
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +9 -5
  65. data/lib/mneme/passive_recall.rb +85 -16
  66. data/lib/mneme/runner.rb +15 -4
  67. data/lib/providers/anthropic.rb +112 -7
  68. data/lib/shell_session.rb +239 -18
  69. data/lib/tools/base.rb +22 -0
  70. data/lib/tools/bash.rb +61 -7
  71. data/lib/tools/edit.rb +2 -2
  72. data/lib/tools/mark_goal_completed.rb +85 -0
  73. data/lib/tools/read.rb +2 -1
  74. data/lib/tools/recall.rb +98 -0
  75. data/lib/tools/registry.rb +41 -7
  76. data/lib/tools/remember.rb +1 -1
  77. data/lib/tools/response_truncator.rb +70 -0
  78. data/lib/tools/spawn_specialist.rb +11 -8
  79. data/lib/tools/spawn_subagent.rb +19 -13
  80. data/lib/tools/subagent_prompts.rb +41 -5
  81. data/lib/tools/think.rb +23 -0
  82. data/lib/tools/write.rb +1 -1
  83. data/lib/tui/app.rb +545 -137
  84. data/lib/tui/braille_spinner.rb +152 -0
  85. data/lib/tui/cable_client.rb +13 -20
  86. data/lib/tui/decorators/base_decorator.rb +40 -11
  87. data/lib/tui/decorators/bash_decorator.rb +3 -3
  88. data/lib/tui/decorators/edit_decorator.rb +7 -4
  89. data/lib/tui/decorators/read_decorator.rb +6 -8
  90. data/lib/tui/decorators/think_decorator.rb +4 -6
  91. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  92. data/lib/tui/decorators/write_decorator.rb +7 -4
  93. data/lib/tui/flash.rb +19 -14
  94. data/lib/tui/formatting.rb +33 -0
  95. data/lib/tui/input_buffer.rb +6 -6
  96. data/lib/tui/message_store.rb +159 -27
  97. data/lib/tui/performance_logger.rb +2 -3
  98. data/lib/tui/screens/chat.rb +302 -103
  99. data/lib/tui/settings.rb +86 -0
  100. data/skills/activerecord/SKILL.md +1 -1
  101. data/skills/dragonruby/SKILL.md +1 -1
  102. data/skills/draper-decorators/SKILL.md +1 -1
  103. data/skills/gh-issue.md +1 -1
  104. data/skills/mcp-server/SKILL.md +1 -1
  105. data/skills/ratatui-ruby/SKILL.md +1 -1
  106. data/skills/rspec/SKILL.md +1 -1
  107. data/templates/config.toml +30 -1
  108. data/templates/tui.toml +209 -0
  109. metadata +24 -3
  110. data/config/initializers/fts5_schema_dump.rb +0 -21
  111. data/lib/environment_probe.rb +0 -232
@@ -16,6 +16,7 @@ class Session < ApplicationRecord
16
16
  serialize :granted_tools, coder: JSON
17
17
 
18
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
22
  has_many :pinned_messages, through: :messages
@@ -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
  #
@@ -59,10 +61,10 @@ class Session < ApplicationRecord
59
61
 
60
62
  # Initialize boundary on first conversation message
61
63
  if mneme_boundary_message_id.nil?
62
- first_conversation = messages.deliverable
64
+ first_conversation = messages
63
65
  .where(message_type: Message::CONVERSATION_TYPES)
64
66
  .order(:id).first
65
- first_conversation ||= messages.deliverable
67
+ first_conversation ||= messages
66
68
  .where(message_type: "tool_call")
67
69
  .detect { |msg| msg.payload["tool_name"] == Message::THINK_TOOL }
68
70
 
@@ -97,29 +99,25 @@ class Session < ApplicationRecord
97
99
  AnalyticalBrainJob.perform_later(id)
98
100
  end
99
101
 
102
+ # Token budget appropriate for this session type.
103
+ # Sub-agents use a smaller budget to stay out of the "dumb zone".
104
+ # @return [Integer]
105
+ def effective_token_budget
106
+ sub_agent? ? Anima::Settings.subagent_token_budget : Anima::Settings.token_budget
107
+ end
108
+
100
109
  # Returns the messages currently visible in the LLM context window.
101
110
  # Walks messages newest-first and includes them until the token budget
102
111
  # is exhausted. Messages are full-size or excluded entirely.
103
112
  #
104
- # Sub-agent sessions inherit parent context via virtual viewport:
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.
113
+ # Pending messages live in a separate table ({PendingMessage}) and never
114
+ # appear in this viewport they are promoted to real messages before
115
+ # the agent processes them.
108
116
  #
109
117
  # @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
118
  # @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
-
117
- if sub_agent? && remaining > 0
118
- parent = select_messages(parent_message_scope(include_pending), budget: remaining)
119
- trim_trailing_tool_calls(parent) + own
120
- else
121
- own
122
- end
119
+ def viewport_messages(token_budget: effective_token_budget)
120
+ select_messages(own_message_scope, budget: token_budget)
123
121
  end
124
122
 
125
123
  # Recalculates the viewport and returns IDs of messages evicted since the
@@ -148,19 +146,47 @@ class Session < ApplicationRecord
148
146
  update_column(:viewport_message_ids, ids)
149
147
  end
150
148
 
149
+ # Returns skill names whose recalled content is currently visible in the
150
+ # viewport. Used by the analytical brain for deduplication — skills already
151
+ # in the viewport are excluded from the activation catalog.
152
+ #
153
+ # @return [Set<String>] skill names present in the viewport
154
+ def skills_in_viewport
155
+ recalled_sources_in_viewport("skill")
156
+ end
157
+
158
+ # Returns the workflow name currently visible in the viewport, if any.
159
+ # Only one workflow can be active at a time, so we return the first match.
160
+ #
161
+ # @return [String, nil] workflow name present in the viewport
162
+ def workflow_in_viewport
163
+ recalled_sources_in_viewport("workflow").first
164
+ end
165
+
151
166
  # 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.
167
+ # Sub-agent sessions use their stored prompt plus active skills and
168
+ # the pinned task. Main sessions assemble a full system prompt from
169
+ # soul and snapshots. Skills, workflows, and goals are injected as
170
+ # phantom tool_use/tool_result pairs in the message stream (not here)
171
+ # to keep the system prompt stable for prompt caching. Environment
172
+ # awareness flows through Bash tool responses.
173
+ #
174
+ # Sub-agent sessions still include expertise inline — they're short-lived
175
+ # and don't benefit from prompt caching.
154
176
  #
155
- # @param environment_context [String, nil] pre-assembled environment block
156
- # from {EnvironmentProbe}; injected between soul and expertise sections
157
177
  # @return [String, nil] the system prompt text, or nil when nothing to inject
158
- def system_prompt(environment_context: nil)
159
- sub_agent? ? prompt : assemble_system_prompt(environment_context: environment_context)
178
+ def system_prompt
179
+ if sub_agent?
180
+ [prompt, assemble_expertise_section, assemble_task_section].compact.join("\n\n")
181
+ else
182
+ assemble_system_prompt
183
+ end
160
184
  end
161
185
 
162
186
  # Activates a skill on this session. Validates the skill exists in the
163
- # registry, adds it to active_skills, and persists.
187
+ # registry, updates active_skills, and enqueues the skill content as a
188
+ # {PendingMessage} so it enters the conversation as a phantom
189
+ # tool_use/tool_result pair through the normal promotion flow.
164
190
  #
165
191
  # @param skill_name [String] name of the skill to activate
166
192
  # @return [Skills::Definition] the activated skill
@@ -174,10 +200,12 @@ class Session < ApplicationRecord
174
200
 
175
201
  self.active_skills = active_skills + [skill_name]
176
202
  save!
203
+ enqueue_recall_message("skill", skill_name, definition.content)
177
204
  definition
178
205
  end
179
206
 
180
207
  # Deactivates a skill on this session. Removes it from active_skills and persists.
208
+ # The skill's recalled message stays in the conversation and evicts naturally.
181
209
  #
182
210
  # @param skill_name [String] name of the skill to deactivate
183
211
  # @return [void]
@@ -189,8 +217,9 @@ class Session < ApplicationRecord
189
217
  end
190
218
 
191
219
  # Activates a workflow on this session. Validates the workflow exists in the
192
- # registry, sets it as the active workflow, and persists. Only one workflow
193
- # can be active at a time — activating a new one replaces the previous.
220
+ # registry, sets it as the active workflow, and enqueues the workflow content
221
+ # as a {PendingMessage}. Only one workflow can be active at a time —
222
+ # activating a new one replaces the previous.
194
223
  #
195
224
  # @param workflow_name [String] name of the workflow to activate
196
225
  # @return [Workflows::Definition] the activated workflow
@@ -204,10 +233,12 @@ class Session < ApplicationRecord
204
233
 
205
234
  self.active_workflow = workflow_name
206
235
  save!
236
+ enqueue_recall_message("workflow", workflow_name, definition.content)
207
237
  definition
208
238
  end
209
239
 
210
240
  # Deactivates the current workflow on this session.
241
+ # The workflow's recalled message stays in the conversation and evicts naturally.
211
242
  #
212
243
  # @return [void]
213
244
  def deactivate_workflow
@@ -217,67 +248,57 @@ class Session < ApplicationRecord
217
248
  save!
218
249
  end
219
250
 
220
- # Assembles the system prompt: version preamble, soul, environment context,
221
- # skills/workflow, then goals.
222
- # The soul is always present "who am I" before "what can I do."
251
+ # Assembles the system prompt: version preamble, soul, and snapshots.
252
+ # Skills, workflows, goals, and environment awareness flow through the
253
+ # message stream and tool responses, keeping the system prompt stable
254
+ # for prompt caching.
223
255
  #
224
- # @param environment_context [String, nil] pre-assembled environment block
225
256
  # @return [String] composed system prompt
226
- def assemble_system_prompt(environment_context: nil)
227
- [assemble_version_preamble, assemble_soul_section, environment_context, assemble_expertise_section, assemble_goals_section].compact.join("\n\n")
257
+ def assemble_system_prompt
258
+ [assemble_version_preamble, assemble_soul_section, assemble_snapshots_section]
259
+ .compact.join("\n\n")
228
260
  end
229
261
 
230
- # Serializes active goals as a lightweight summary for ActionCable
262
+ # Serializes non-evicted goals as a lightweight summary for ActionCable
231
263
  # broadcasts and TUI display. Returns a nested structure: root goals
232
- # with their sub-goals inlined.
264
+ # with their sub-goals inlined. Evicted goals and their sub-goals are
265
+ # excluded.
233
266
  #
234
267
  # @return [Array<Hash>] each with :id, :description, :status, and :sub_goals
235
268
  def goals_summary
236
- goals.root.includes(:sub_goals).order(:created_at).map(&:as_summary)
269
+ goals.root.not_evicted.includes(:sub_goals).order(:created_at).map(&:as_summary)
237
270
  end
238
271
 
239
272
  # Builds the message array expected by the Anthropic Messages API.
240
273
  # Viewport layout (top to bottom):
241
- # [L2 snapshots] [L1 snapshots] [pinned messages] [recalled memories] [sliding window messages]
274
+ # [context prefix: goals + pinned messages] [sliding window messages]
242
275
  #
243
- # Snapshots appear ONLY after their source messages have evicted from
244
- # the sliding window. L1 snapshots drop once covered by an L2 snapshot.
245
- # Pinned messages are critical context attached to active Goals they
246
- # survive eviction intact until their Goals complete.
247
- # Recalled memories surface relevant older messages (passive recall via goals).
248
- # Each layer has a fixed token budget fraction — snapshots, pins, and recall
249
- # consume viewport space, reducing the sliding window size.
276
+ # Snapshots live in the system prompt (stable between Mneme runs).
277
+ # Goal events and recalled memories flow through the message stream as
278
+ # phantom tool pairs they ride the conveyor belt as regular messages.
279
+ # After eviction, a goal snapshot + pinned messages block is rebuilt
280
+ # from DB state and prepended as a phantom pair.
250
281
  #
251
- # Sub-agent sessions skip snapshot/pin/recall injection (they inherit parent messages directly).
282
+ # The sliding window is post-processed by {#ensure_atomic_tool_pairs}
283
+ # which removes orphaned tool messages whose partner was cut off by the
284
+ # token budget.
252
285
  #
253
286
  # @param token_budget [Integer] maximum tokens to include (positive)
254
287
  # @return [Array<Hash>] Anthropic Messages API format
255
- def messages_for_llm(token_budget: Anima::Settings.token_budget)
288
+ def messages_for_llm(token_budget: effective_token_budget)
256
289
  heal_orphaned_tool_calls!
257
290
 
258
291
  sliding_budget = token_budget
259
- snapshot_messages = []
260
- pinned_messages = []
261
- recall_messages = []
262
292
 
263
- unless sub_agent?
264
- l2_budget = (token_budget * Anima::Settings.mneme_l2_budget_fraction).to_i
265
- l1_budget = (token_budget * Anima::Settings.mneme_l1_budget_fraction).to_i
266
- pinned_budget = (token_budget * Anima::Settings.mneme_pinned_budget_fraction).to_i
267
- recall_budget = (token_budget * Anima::Settings.recall_budget_fraction).to_i
268
- sliding_budget = token_budget - l2_budget - l1_budget - pinned_budget - recall_budget
269
- end
293
+ pinned_budget = (token_budget * Anima::Settings.mneme_pinned_budget_fraction).to_i
294
+ sliding_budget -= pinned_budget
270
295
 
271
- window = viewport_messages(token_budget: sliding_budget, include_pending: false)
296
+ window = viewport_messages(token_budget: sliding_budget)
297
+ first_message_id = window.first&.id
272
298
 
273
- unless sub_agent?
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
- recall_messages = assemble_recall_messages(budget: recall_budget)
278
- end
299
+ prefix = assemble_context_prefix_messages(first_message_id, budget: pinned_budget)
279
300
 
280
- snapshot_messages + pinned_messages + recall_messages + assemble_messages(ensure_atomic_tool_pairs(window))
301
+ prefix + assemble_messages(ensure_atomic_tool_pairs(window))
281
302
  end
282
303
 
283
304
  # Detects orphaned tool_call messages (those without a matching tool_response
@@ -293,7 +314,7 @@ class Session < ApplicationRecord
293
314
  #
294
315
  # @return [Integer] number of synthetic responses created
295
316
  def heal_orphaned_tool_calls!
296
- now_ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
317
+ current_ns = now_ns
297
318
  responded_ids = messages.where(message_type: "tool_response").select(:tool_use_id)
298
319
  unresponded = messages.where(message_type: "tool_call")
299
320
  .where.not(tool_use_id: responded_ids)
@@ -302,7 +323,7 @@ class Session < ApplicationRecord
302
323
  unresponded.find_each do |orphan|
303
324
  timeout = orphan.payload["timeout"] || Anima::Settings.tool_timeout
304
325
  deadline_ns = orphan.timestamp + (timeout * 1_000_000_000)
305
- next if now_ns < deadline_ns
326
+ next if current_ns < deadline_ns
306
327
 
307
328
  messages.create!(
308
329
  message_type: "tool_response",
@@ -314,7 +335,7 @@ class Session < ApplicationRecord
314
335
  "success" => false
315
336
  },
316
337
  tool_use_id: orphan.tool_use_id,
317
- timestamp: now_ns
338
+ timestamp: current_ns
318
339
  )
319
340
  healed += 1
320
341
  end
@@ -324,59 +345,119 @@ class Session < ApplicationRecord
324
345
  # Delivers a user message respecting the session's processing state.
325
346
  #
326
347
  # When idle, persists the message 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.
348
+ # to process it. When mid-turn ({#processing?}), stages the message as
349
+ # a {PendingMessage} in a separate table — it gets no message ID until
350
+ # promoted, so it can never interleave with tool_call/tool_response pairs.
331
351
  #
332
- # @param content [String] user message text
352
+ # @param content [String] message text (raw, without attribution)
353
+ # @param source_type [String] origin type: "user" (default) or "subagent"
354
+ # @param source_name [String, nil] sub-agent nickname (required when source_type is "subagent")
333
355
  # @param bounce_back [Boolean] when true, passes +message_id+ to the job
334
356
  # so failed LLM delivery triggers a {Events::BounceBack} (used by
335
357
  # {SessionChannel#speak} for immediate-display messages)
336
358
  # @return [void]
337
- def enqueue_user_message(content, bounce_back: false)
359
+ def enqueue_user_message(content, source_type: "user", source_name: nil, bounce_back: false)
338
360
  if processing?
339
- Events::Bus.emit(Events::UserMessage.new(
340
- content: content, session_id: id,
341
- status: Message::PENDING_STATUS
342
- ))
361
+ pending_messages.create!(content: content, source_type: source_type, source_name: source_name)
343
362
  else
344
- msg = create_user_message(content)
363
+ display = if source_type == "subagent"
364
+ format(Tools::ResponseTruncator::ATTRIBUTION_FORMAT, source_name, content)
365
+ else
366
+ content
367
+ end
368
+ msg = create_user_message(display)
345
369
  job_args = bounce_back ? {message_id: msg.id} : {}
346
370
  AgentRequestJob.perform_later(id, **job_args)
347
371
  end
348
372
  end
349
373
 
374
+ # Promotes a phantom pair pending message into a tool_call/tool_response pair.
375
+ # These persist as real Message records and ride the conveyor belt.
376
+ #
377
+ # @param pm [PendingMessage] phantom pair pending message
378
+ # @return [void]
379
+ def promote_phantom_pair!(pm)
380
+ tool_name = pm.phantom_tool_name
381
+ tool_input = pm.phantom_tool_input
382
+ uid = "#{tool_name}_#{pm.id}"
383
+ now = now_ns
384
+
385
+ messages.create!(
386
+ message_type: "tool_call",
387
+ tool_use_id: uid,
388
+ payload: {"tool_name" => tool_name, "tool_use_id" => uid,
389
+ "tool_input" => tool_input.stringify_keys,
390
+ "content" => pm.display_content.lines.first.chomp},
391
+ timestamp: now,
392
+ token_count: Mneme::PassiveRecall::TOOL_PAIR_OVERHEAD_TOKENS
393
+ )
394
+
395
+ messages.create!(
396
+ message_type: "tool_response",
397
+ tool_use_id: uid,
398
+ payload: {"tool_name" => tool_name, "tool_use_id" => uid,
399
+ "content" => pm.content, "success" => true},
400
+ timestamp: now,
401
+ token_count: Message.estimate_token_count(pm.content.bytesize)
402
+ )
403
+ end
404
+
350
405
  # Persists a user message directly, bypassing the pending queue.
351
406
  #
352
- # Used by {#enqueue_user_message} (idle path), {AgentLoop#process},
407
+ # Used by {#enqueue_user_message} (idle path), {AgentLoop#run},
353
408
  # and sub-agent spawn tools ({Tools::SpawnSubagent}, {Tools::SpawnSpecialist})
354
409
  # because the global {Events::Subscribers::Persister} skips non-pending user
355
410
  # messages — these callers own the persistence lifecycle.
356
411
  #
357
412
  # @param content [String] user message text
413
+ # @param source_type [String, nil] origin type (e.g. "skill", "workflow")
414
+ # for viewport tracking; omitted for plain user messages
415
+ # @param source_name [String, nil] origin name (e.g. skill name)
358
416
  # @return [Message] the persisted message record
359
- def create_user_message(content)
360
- now = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
417
+ def create_user_message(content, source_type: nil, source_name: nil)
418
+ now = now_ns
419
+ payload = {type: "user_message", content: content, session_id: id, timestamp: now}
420
+ payload["source_type"] = source_type if source_type
421
+ payload["source_name"] = source_name if source_name
361
422
  messages.create!(
362
423
  message_type: "user_message",
363
- payload: {type: "user_message", content: content, session_id: id, timestamp: now},
424
+ payload: payload,
364
425
  timestamp: now
365
426
  )
366
427
  end
367
428
 
368
- # Promotes all pending user messages to delivered status so they
369
- # appear in the next LLM context. Triggers broadcast_update for
370
- # each message so connected clients refresh the pending indicator.
429
+ # Promotes all pending messages into the conversation history.
430
+ # Each {PendingMessage} is atomically deleted and replaced with a real
431
+ # {Message} the new message gets the next auto-increment ID,
432
+ # naturally placing it after any tool_call/tool_response pairs that
433
+ # were persisted while the message was waiting.
434
+ #
435
+ # Returns a hash with two keys:
436
+ # - +:texts+ — plain content strings for user messages (injected as text blocks
437
+ # within the current tool_results turn)
438
+ # - +:pairs+ — synthetic tool_use/tool_result message hashes for phantom pair
439
+ # types (appended as new conversation turns)
371
440
  #
372
- # @return [Integer] number of promoted messages
441
+ # @return [Hash{Symbol => Array}] promoted messages split by injection strategy
373
442
  def promote_pending_messages!
374
- promoted = 0
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
- promoted += 1
443
+ texts = []
444
+ pairs = []
445
+ pending_messages.find_each do |pm|
446
+ transaction do
447
+ if pm.phantom_pair?
448
+ promote_phantom_pair!(pm)
449
+ else
450
+ create_user_message(pm.display_content, source_type: pm.source_type, source_name: pm.source_name)
451
+ end
452
+ pm.destroy!
453
+ end
454
+ if pm.phantom_pair?
455
+ pairs.concat(pm.to_llm_messages)
456
+ else
457
+ texts << pm.content
458
+ end
378
459
  end
379
- promoted
460
+ {texts: texts, pairs: pairs}
380
461
  end
381
462
 
382
463
  # Broadcasts child session list to all clients subscribed to the parent
@@ -396,12 +477,133 @@ class Session < ApplicationRecord
396
477
  ActionCable.server.broadcast("session_#{parent_session_id}", {
397
478
  "action" => "children_updated",
398
479
  "session_id" => parent_session_id,
399
- "children" => children.map { |child| {"id" => child.id, "name" => child.name, "processing" => child.processing?} }
480
+ "children" => children.map { |child|
481
+ state = child.processing? ? "llm_generating" : "idle"
482
+ {"id" => child.id, "name" => child.name, "processing" => child.processing?, "session_state" => state}
483
+ }
400
484
  })
401
485
  end
402
486
 
487
+ # Broadcasts the session's current processing state to all subscribed
488
+ # clients. Stateless — no storage, pure broadcast. The TUI uses this to
489
+ # drive the braille spinner animation and sub-agent HUD icons.
490
+ #
491
+ # Payload broadcast to +session_{id}+:
492
+ # {"action" => "session_state", "state" => state, "session_id" => id}
493
+ # # plus "tool" key when state is "tool_executing"
494
+ #
495
+ # For sub-agents, also broadcasts +child_state+ to the parent stream:
496
+ # {"action" => "child_state", "state" => state, "session_id" => id, "child_id" => id}
497
+ #
498
+ # @param state [String] one of "idle", "llm_generating", "tool_executing", "interrupting"
499
+ # @param tool [String, nil] tool name when state is "tool_executing"
500
+ # @return [void]
501
+ def broadcast_session_state(state, tool: nil)
502
+ payload = {"action" => "session_state", "state" => state, "session_id" => id}
503
+ payload["tool"] = tool if tool
504
+ ActionCable.server.broadcast("session_#{id}", payload)
505
+
506
+ # Notify the parent's stream so the HUD updates child state icons
507
+ # without requiring a full children_updated query.
508
+ return unless parent_session_id
509
+
510
+ parent_payload = payload.merge("action" => "child_state", "child_id" => id)
511
+ ActionCable.server.broadcast("session_#{parent_session_id}", parent_payload)
512
+ end
513
+
514
+ # Broadcasts the full LLM debug context to debug-mode TUI clients.
515
+ # Called on every LLM request so the TUI shows exactly what the LLM
516
+ # receives — system prompt and tool schemas. No-op outside debug mode.
517
+ #
518
+ # @param system [String, nil] the final system prompt sent to the LLM
519
+ # @param tools [Array<Hash>, nil] tool schemas sent to the LLM
520
+ # @return [void]
521
+ def broadcast_debug_context(system:, tools: nil)
522
+ return unless view_mode == "debug" && system
523
+
524
+ ActionCable.server.broadcast("session_#{id}", self.class.system_prompt_payload(system, tools: tools))
525
+ end
526
+
527
+ # Returns the deterministic tool schemas for this session's type and
528
+ # granted_tools configuration. Standard and spawn tools are static
529
+ # class-level definitions — no ShellSession or registry needed.
530
+ # MCP tools are excluded (they require live server queries and appear
531
+ # after the first LLM request via {#broadcast_debug_context}).
532
+ #
533
+ # @return [Array<Hash>] tool schema hashes matching Anthropic tools API format
534
+ def tool_schemas
535
+ tools = if granted_tools
536
+ granted = granted_tools.filter_map { |name| AgentLoop::STANDARD_TOOLS_BY_NAME[name] }
537
+ (AgentLoop::ALWAYS_GRANTED_TOOLS + granted).uniq
538
+ else
539
+ AgentLoop::STANDARD_TOOLS.dup
540
+ end
541
+
542
+ unless sub_agent?
543
+ tools.push(Tools::SpawnSubagent, Tools::SpawnSpecialist, Tools::OpenIssue)
544
+ end
545
+
546
+ if sub_agent?
547
+ tools.push(Tools::MarkGoalCompleted)
548
+ end
549
+
550
+ tools.map(&:schema)
551
+ end
552
+
553
+ # Builds the system prompt payload for debug mode transmission.
554
+ # Token estimate covers both the system prompt and tool schemas
555
+ # since both consume the LLM's context window.
556
+ # Tools are sent as raw schemas; the TUI formats them as TOON for display.
557
+ #
558
+ # @param prompt [String] system prompt text
559
+ # @param tools [Array<Hash>, nil] tool schemas
560
+ # @return [Hash] payload with type, rendered debug content, and token estimate
561
+ def self.system_prompt_payload(prompt, tools: nil)
562
+ total_bytes = prompt.bytesize
563
+ total_bytes += tools.to_json.bytesize if tools&.any?
564
+ tokens = Message.estimate_token_count(total_bytes)
565
+
566
+ debug = {role: :system_prompt, content: prompt, tokens: tokens, estimated: true}
567
+ debug[:tools] = tools if tools&.any?
568
+
569
+ {
570
+ "id" => Message::SYSTEM_PROMPT_ID,
571
+ "type" => "system_prompt",
572
+ "rendered" => {"debug" => debug}
573
+ }
574
+ end
575
+
403
576
  private
404
577
 
578
+ # Finds recalled skill/workflow source names in the current viewport.
579
+ # Scans viewport messages for user_messages tagged with the given source_type.
580
+ #
581
+ # @param source_type [String] "skill" or "workflow"
582
+ # @return [Set<String>] source names present in the viewport
583
+ def recalled_sources_in_viewport(source_type)
584
+ ids = viewport_message_ids
585
+ return Set.new if ids.empty?
586
+
587
+ messages
588
+ .where(id: ids, message_type: "user_message")
589
+ .where("json_extract(payload, '$.source_type') = ?", source_type)
590
+ .pluck(Arel.sql("json_extract(payload, '$.source_name')"))
591
+ .to_set
592
+ end
593
+
594
+ # Enqueues a recalled skill or workflow as a {PendingMessage}.
595
+ # Always goes through the pending queue because the analytical brain
596
+ # only runs during processing. The message enters the conversation
597
+ # through the normal promotion flow as a phantom tool_use/tool_result pair.
598
+ #
599
+ # @param source_type [String] "skill" or "workflow"
600
+ # @param source_name [String] skill or workflow name
601
+ # @param content [String] definition content to recall
602
+ # @return [PendingMessage] the created pending message
603
+ def enqueue_recall_message(source_type, source_name, content)
604
+ pending_messages.create!(content: content, source_type: source_type, source_name: source_name)
605
+ end
606
+
405
607
  # One-line version preamble so the agent knows its own version.
406
608
  # Useful for commits, handoffs, and debugging.
407
609
  #
@@ -446,17 +648,24 @@ class Session < ApplicationRecord
446
648
  "## Your Expertise\n\nYou know this deeply. Now's your chance to put it to work.\n\n#{sections.join("\n\n")}"
447
649
  end
448
650
 
449
- # Assembles the goals section of the system prompt.
450
- # Active root goals render as `###` headings with sub-goal checkboxes.
451
- # Completed root goals collapse to a single strikethrough line.
651
+ # Assembles the task section for sub-agent system prompts.
652
+ # Sub-agents have a single pinned goal their entire raison d'etre.
653
+ # Rendered as a persistent task block so the LLM always knows what it
654
+ # was spawned to do, regardless of conversation length.
452
655
  #
453
- # @return [String, nil] goals section, or nil when no goals exist
454
- def assemble_goals_section
455
- root_goals = goals.root.includes(:sub_goals).order(:created_at)
456
- return if root_goals.empty?
656
+ # @return [String, nil] task section, or nil when no active goal exists
657
+ def assemble_task_section
658
+ goal = goals.active.root.first
659
+ return unless goal
660
+
661
+ <<~SECTION.strip
662
+ Your Task
663
+ =========
664
+
665
+ #{goal.description}
457
666
 
458
- entries = root_goals.map { |goal| render_goal_markdown(goal) }
459
- "## Current Goals\n\n#{entries.join("\n\n")}"
667
+ Complete this task and call mark_goal_completed when done.
668
+ SECTION
460
669
  end
461
670
 
462
671
  # Renders a single root goal with its sub-goals as Markdown.
@@ -529,22 +738,13 @@ class Session < ApplicationRecord
529
738
  end
530
739
 
531
740
  # Scopes own messages for viewport assembly.
741
+ # Starts from the Mneme boundary (inclusive) — older messages have been
742
+ # compressed into snapshots and no longer participate in the viewport.
532
743
  # @return [ActiveRecord::Relation]
533
- def own_message_scope(include_pending)
744
+ def own_message_scope
534
745
  scope = messages.context_messages
535
- include_pending ? scope : scope.deliverable
536
- end
537
-
538
- # Scopes parent messages created before this session's fork point.
539
- # Excludes spawn tool messages — sub-agents don't need to see sibling
540
- # spawn pairs, which cause role confusion (the sub-agent mistakes
541
- # itself for the parent when it sees "Specialist @sibling spawned...").
542
- # @return [ActiveRecord::Relation]
543
- def parent_message_scope(include_pending)
544
- scope = parent_session.messages.context_messages
545
- .excluding_spawn_messages
546
- .where(created_at: ...created_at)
547
- include_pending ? scope : scope.deliverable
746
+ scope = scope.where("messages.id >= ?", mneme_boundary_message_id) if mneme_boundary_message_id
747
+ scope
548
748
  end
549
749
 
550
750
  # Walks messages newest-first, selecting until the token budget is exhausted.
@@ -573,21 +773,16 @@ class Session < ApplicationRecord
573
773
  (msg.token_count > 0) ? msg.token_count : estimate_tokens(msg)
574
774
  end
575
775
 
576
- # Removes trailing tool_call messages that lack matching tool_response.
577
- # Prevents orphaned tool_use blocks at the parent/child viewport boundary
578
- # (the spawn_subagent/spawn_specialist tool_call is emitted before the child exists,
579
- # but its tool_response comes after — so the cutoff can split them).
580
- def trim_trailing_tool_calls(message_list)
581
- message_list.pop while message_list.last&.message_type == "tool_call"
582
- message_list
583
- end
584
-
585
776
  # Ensures every tool_call in the message list has a matching tool_response
586
777
  # (and vice versa) by removing unpaired messages. The Anthropic API requires
587
778
  # every tool_use block to have a tool_result — a missing partner causes
588
779
  # a permanent API error. Token budget cutoffs can split pairs when the
589
780
  # boundary falls between a tool_call and its tool_response.
590
781
  #
782
+ # Still necessary even though {#assemble_messages} pairs by +tool_use_id+:
783
+ # the assembly assumes every tool_call has a matching response in the window.
784
+ # This guard ensures that assumption holds after viewport truncation.
785
+ #
591
786
  # @param message_list [Array<Message>] chronologically ordered messages
592
787
  # @return [Array<Message>] messages with unpaired tool messages removed
593
788
  def ensure_atomic_tool_pairs(message_list)
@@ -604,28 +799,32 @@ class Session < ApplicationRecord
604
799
  message_list.reject { |m| m.tool_use_id.present? && !complete_ids.include?(m.tool_use_id) }
605
800
  end
606
801
 
607
- # Selects visible snapshots and formats them as Anthropic messages.
608
- # Snapshots are visible when their source messages have fully evicted.
609
- # L1 snapshots are excluded when covered by an L2 snapshot.
802
+ # Assembles L1/L2 snapshots as a system prompt section.
803
+ # Snapshots are visible when their source messages precede the Mneme boundary
804
+ # (compressed in a previous run). Between Mneme runs this section is frozen,
805
+ # making it cache-friendly.
610
806
  #
611
- # @param first_message_id [Integer, nil] first message ID in the sliding window
612
- # @param l2_budget [Integer] token budget for L2 snapshots
613
- # @param l1_budget [Integer] token budget for L1 snapshots
614
- # @return [Array<Hash>] Anthropic Messages API format
615
- def assemble_snapshot_messages(first_message_id, l2_budget:, l1_budget:)
616
- return [] unless first_message_id
807
+ # @return [String, nil] formatted snapshot text for the system prompt, or nil
808
+ def assemble_snapshots_section
809
+ reference_id = mneme_boundary_message_id || viewport_message_ids.first
810
+ return unless reference_id
617
811
 
618
- l2_messages = select_snapshots_within_budget(
619
- snapshots.for_level(2).source_messages_evicted(first_message_id).chronological,
620
- budget: l2_budget
621
- ).map { |snapshot| format_snapshot_message(snapshot, label: "long-term memory") }
812
+ l2_budget = (Anima::Settings.token_budget * Anima::Settings.mneme_l2_budget_fraction).to_i
813
+ l1_budget = (Anima::Settings.token_budget * Anima::Settings.mneme_l1_budget_fraction).to_i
622
814
 
623
- l1_messages = select_snapshots_within_budget(
624
- snapshots.for_level(1).not_covered_by_l2.source_messages_evicted(first_message_id).chronological,
815
+ l2 = select_snapshots_within_budget(
816
+ snapshots.for_level(2).source_messages_evicted(reference_id).chronological,
817
+ budget: l2_budget
818
+ )
819
+ l1 = select_snapshots_within_budget(
820
+ snapshots.for_level(1).not_covered_by_l2.source_messages_evicted(reference_id).chronological,
625
821
  budget: l1_budget
626
- ).map { |snapshot| format_snapshot_message(snapshot, label: "recent memory") }
822
+ )
627
823
 
628
- l2_messages + l1_messages
824
+ sections = []
825
+ sections << format_snapshots_text(l2, label: "Long-term Memory") if l2.any?
826
+ sections << format_snapshots_text(l1, label: "Recent Memory") if l1.any?
827
+ sections.join("\n\n").presence
629
828
  end
630
829
 
631
830
  # Walks snapshots chronologically, selecting until the token budget is exhausted.
@@ -650,40 +849,53 @@ class Session < ApplicationRecord
650
849
  selected
651
850
  end
652
851
 
653
- # Formats a snapshot as an Anthropic user message with a memory label prefix.
852
+ # Formats a list of snapshots as a labeled section for the system prompt.
654
853
  #
655
- # @param snapshot [Snapshot]
656
- # @param label [String] human-readable label (e.g. "recent memory", "long-term memory")
657
- # @return [Hash] Anthropic message format
658
- def format_snapshot_message(snapshot, label:)
659
- {role: "user", content: "[#{label}]\n#{snapshot.text}"}
854
+ # @param snapshots_list [Array<Snapshot>]
855
+ # @param label [String] section heading
856
+ # @return [String]
857
+ def format_snapshots_text(snapshots_list, label:)
858
+ texts = snapshots_list.map(&:text)
859
+ "## #{label}\n\n#{texts.join("\n\n")}"
660
860
  end
661
861
 
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).
862
+ # Assembles the context prefix: active goals snapshot + pinned messages.
863
+ # Only shown after the first eviction before that, goal events flow
864
+ # as phantom pairs in the message stream and pinned messages have not
865
+ # yet evicted.
665
866
  #
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.
867
+ # Returns a phantom tool_call/tool_result pair so the LLM sees a
868
+ # coherent goals + pins block it "recalled" via a tool invocation.
668
869
  #
669
870
  # @param first_message_id [Integer, nil] first message ID in the sliding window
670
- # @param budget [Integer] token budget for pinned messages
671
- # @return [Array<Hash>] Anthropic Messages API format (0 or 1 messages)
672
- def assemble_pinned_section_messages(first_message_id, budget:)
871
+ # @param budget [Integer] token budget for context prefix
872
+ # @return [Array<Hash>] Anthropic Messages API format (0 or 2 messages)
873
+ def assemble_context_prefix_messages(first_message_id, budget:)
673
874
  return [] unless first_message_id
875
+ return [] unless messages.where("id < ?", first_message_id).exists?
876
+
877
+ root_goals = goals.root.active.includes(:sub_goals).order(:created_at)
878
+ return [] if root_goals.empty?
674
879
 
675
880
  pins = pinned_messages
676
881
  .includes(:message, :goals)
677
882
  .where("pinned_messages.message_id < ?", first_message_id)
678
883
  .order("pinned_messages.message_id")
679
884
 
680
- return [] if pins.empty?
681
-
682
- selected = select_pins_within_budget(pins, budget)
683
- return [] if selected.empty?
684
-
685
- text = render_pinned_messages_section(selected)
686
- [{role: "user", content: "[pinned messages]\n#{text}"}]
885
+ selected_pins = select_pins_within_budget(pins, budget)
886
+ content = render_goal_snapshot_with_pins(root_goals, selected_pins)
887
+
888
+ # Uses session ID (not PendingMessage ID) because this snapshot is
889
+ # rebuilt from DB state on every eviction — it has no stable PM record.
890
+ uid = "goal_snapshot_#{id}"
891
+ [
892
+ {role: "assistant", content: [
893
+ {type: "tool_use", id: uid, name: PendingMessage::RECALL_GOAL_TOOL, input: {}}
894
+ ]},
895
+ {role: "user", content: [
896
+ {type: "tool_result", tool_use_id: uid, content: content}
897
+ ]}
898
+ ]
687
899
  end
688
900
 
689
901
  # Walks pinned messages chronologically, selecting until the token budget
@@ -707,23 +919,33 @@ class Session < ApplicationRecord
707
919
  selected
708
920
  end
709
921
 
710
- # Renders the pinned messages section grouped by Goal.
711
- # First Goal referencing a pin shows truncated text; subsequent Goals
712
- # show bare `message N` ID to avoid token-expensive repetition.
922
+ # Renders active goals with their associated pinned messages as a
923
+ # combined snapshot. Each goal shows its sub-goals and any pinned
924
+ # messages attached to it.
713
925
  #
926
+ # @param root_goals [Array<Goal>] active root goals with preloaded sub_goals
714
927
  # @param pins [Array<PinnedMessage>] selected pins with preloaded goals
715
- # @return [String] formatted section text
716
- def render_pinned_messages_section(pins)
717
- goal_pins = group_pins_by_active_goal(pins)
718
-
928
+ # @return [String] formatted goals + pins block
929
+ def render_goal_snapshot_with_pins(root_goals, pins)
930
+ pin_groups = group_pins_by_active_goal(pins)
719
931
  shown_messages = Set.new
720
- goal_pins.map { |goal, pin_list|
721
- render_goal_pins(goal, pin_list, shown_messages)
722
- }.join("\n\n")
932
+
933
+ sections = root_goals.map { |goal|
934
+ lines = [render_goal_markdown(goal)]
935
+ goal_pins = pin_groups[goal]
936
+ if goal_pins
937
+ lines << ""
938
+ goal_pins.each { |pin| lines << format_pin_line(pin, shown_messages) }
939
+ end
940
+ lines.join("\n")
941
+ }
942
+
943
+ "Current Goals\n=============\n\n#{sections.join("\n\n")}"
723
944
  end
724
945
 
725
946
  # Groups pins by their active Goals so the viewport renders
726
- # one headed section per Goal.
947
+ # one headed section per Goal. Relies on +:goals+ being eager-loaded
948
+ # on each pin — without it, +active_goal_pin_pairs+ triggers N+1.
727
949
  #
728
950
  # @param pins [Array<PinnedMessage>] pins with preloaded goals
729
951
  # @return [Hash{Goal => Array<PinnedMessage>}]
@@ -741,18 +963,6 @@ class Session < ApplicationRecord
741
963
  pin.goals.select(&:active?).map { |goal| [goal, pin] }
742
964
  end
743
965
 
744
- # Renders one Goal's pinned messages as a headed list.
745
- #
746
- # @param goal [Goal]
747
- # @param pin_list [Array<PinnedMessage>]
748
- # @param shown_messages [Set<Integer>] tracks already-rendered message IDs for dedup
749
- # @return [String]
750
- def render_goal_pins(goal, pin_list, shown_messages)
751
- lines = ["📌 #{goal.description} (id: #{goal.id})"]
752
- pin_list.each { |pin| lines << format_pin_line(pin, shown_messages) }
753
- lines.join("\n")
754
- end
755
-
756
966
  # Formats a single pin line with deduplication: first occurrence shows
757
967
  # truncated text, subsequent occurrences show bare message ID only.
758
968
  #
@@ -762,109 +972,100 @@ class Session < ApplicationRecord
762
972
  def format_pin_line(pin, shown_messages)
763
973
  mid = pin.message_id
764
974
  if shown_messages.add?(mid)
765
- " message #{mid}: #{pin.display_text}"
766
- else
767
- " message #{mid}"
768
- end
769
- end
770
-
771
- # Assembles recalled memory messages from passive recall results.
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.
774
- #
775
- # @param budget [Integer] token budget for recall messages
776
- # @return [Array<Hash>] Anthropic Messages API format
777
- def assemble_recall_messages(budget:)
778
- return [] if recalled_message_ids.blank?
779
-
780
- recalled = Message.where(id: recalled_message_ids)
781
- .includes(:session)
782
- .index_by(&:id)
783
-
784
- snippets = []
785
- remaining = budget
786
-
787
- recalled_message_ids.each do |mid|
788
- msg = recalled[mid]
789
- next unless msg
790
-
791
- text = format_recall_snippet(msg)
792
- cost = [(text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
793
- break if cost > remaining && snippets.any?
794
-
795
- snippets << text
796
- remaining -= cost
797
- end
798
-
799
- return [] if snippets.empty?
800
-
801
- [{role: "user", content: "[associative recall]\n#{snippets.join("\n\n")}"}]
802
- end
803
-
804
- # Formats a recalled message as a compact snippet with enough context
805
- # for the agent to decide whether to drill down with the remember tool.
806
- #
807
- # @param msg [Message] the recalled message
808
- # @return [String] formatted snippet
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}"
813
- end
814
-
815
- # Extracts readable content from a message's payload.
816
- #
817
- # @param msg [Message]
818
- # @return [String]
819
- def extract_message_content(msg)
820
- data = msg.payload
821
- case msg.message_type
822
- when "user_message", "agent_message", "system_message"
823
- data["content"]
824
- when "tool_call"
825
- if data["tool_name"] == Message::THINK_TOOL
826
- data.dig("tool_input", "thoughts")
827
- else
828
- "#{data["tool_name"]}(…)"
829
- end
975
+ " 📌 message #{mid}: #{pin.display_text}"
830
976
  else
831
- data["content"]
977
+ " 📌 message #{mid}"
832
978
  end
833
979
  end
834
980
 
835
981
  # Converts a chronological list of messages into Anthropic wire-format messages.
836
982
  # Prepends a compact timestamp to each user message for LLM time awareness.
837
- # Groups consecutive tool_call messages into one assistant message and
838
- # consecutive tool_response messages into one user message.
839
983
  #
840
- # @param msgs [Array<Message>]
841
- # @return [Array<Hash>]
984
+ # Tool pairing uses +tool_use_id+ lookup, not message order. When a batch
985
+ # of consecutive +tool_call+ messages is encountered, all matching
986
+ # +tool_response+ messages are found by +tool_use_id+ and emitted as a
987
+ # single user message immediately after the assistant message. This
988
+ # guarantees correct API structure even when responses are persisted
989
+ # out of order (e.g. parallel tool execution, interleaved sub-agent
990
+ # deliveries, or promoted pending messages).
991
+ #
992
+ # Assumes +ensure_atomic_tool_pairs+ has already removed any unpaired
993
+ # tool messages from the window.
994
+ #
995
+ # @param msgs [Array<Message>] chronologically ordered (by id), pre-filtered
996
+ # @return [Array<Hash>] Anthropic API message format
842
997
  def assemble_messages(msgs)
843
- msgs.each_with_object([]) do |msg, api_messages|
998
+ response_index = build_tool_response_index(msgs)
999
+
1000
+ result = []
1001
+ i = 0
1002
+ while i < msgs.length
1003
+ msg = msgs[i]
1004
+
844
1005
  case msg.message_type
845
1006
  when "user_message"
846
- content = "#{format_message_time(msg.timestamp)}\n#{msg.payload["content"]}"
847
- api_messages << {role: "user", content: content}
1007
+ result << {role: "user", content: "#{format_message_time(msg.timestamp)}\n#{msg.payload["content"]}"}
1008
+ i += 1
848
1009
  when "agent_message"
849
- api_messages << {role: "assistant", content: msg.payload["content"].to_s}
1010
+ result << {role: "assistant", content: msg.payload["content"].to_s}
1011
+ i += 1
850
1012
  when "tool_call"
851
- append_grouped_block(api_messages, "assistant", tool_use_block(msg.payload))
1013
+ i = assemble_tool_pair(msgs, i, response_index, result)
852
1014
  when "tool_response"
853
- append_grouped_block(api_messages, "user", tool_result_block(msg.payload))
1015
+ # Already emitted by assemble_tool_pair via tool_use_id lookup.
1016
+ # Any response still here was orphaned by viewport eviction
1017
+ # and should have been stripped by ensure_atomic_tool_pairs.
1018
+ i += 1
854
1019
  when "system_message"
855
- # Wrapped as user role with prefix — Claude API has no system role in conversation history
856
- api_messages << {role: "user", content: "[system] #{msg.payload["content"]}"}
1020
+ result << {role: "user", content: "[system] #{msg.payload["content"]}"}
1021
+ i += 1
1022
+ else
1023
+ i += 1
857
1024
  end
858
1025
  end
1026
+
1027
+ result
859
1028
  end
860
1029
 
861
- # Groups consecutive tool blocks into a single message of the given role.
862
- def append_grouped_block(api_messages, role, block)
863
- prev = api_messages.last
864
- if prev&.dig(:role) == role && prev[:content].is_a?(Array)
865
- prev[:content] << block
866
- else
867
- api_messages << {role: role, content: [block]}
1030
+ # Collects a batch of consecutive tool_call messages starting at +start+,
1031
+ # emits one assistant message with all tool_use blocks, then emits one
1032
+ # user message with matching tool_result blocks found by tool_use_id.
1033
+ #
1034
+ # @param msgs [Array<Message>] the full message list
1035
+ # @param start [Integer] index of the first tool_call in the batch
1036
+ # @param response_index [Hash{String => Message}] tool_use_id → tool_response
1037
+ # @param result [Array<Hash>] accumulator for assembled API messages
1038
+ # @return [Integer] index of the first message after the batch
1039
+ def assemble_tool_pair(msgs, start, response_index, result)
1040
+ # Collect consecutive tool_calls (same LLM turn)
1041
+ batch = []
1042
+ i = start
1043
+ while i < msgs.length && msgs[i].message_type == "tool_call"
1044
+ batch << msgs[i]
1045
+ i += 1
1046
+ end
1047
+
1048
+ # Assistant message: all tool_use blocks
1049
+ result << {role: "assistant", content: batch.map { |tc| tool_use_block(tc.payload) }}
1050
+
1051
+ # User message: matching tool_result blocks, paired by tool_use_id
1052
+ tool_results = batch.filter_map do |tc|
1053
+ response = response_index[tc.tool_use_id]
1054
+ next unless response
1055
+ tool_result_block(response.payload)
1056
+ end
1057
+ result << {role: "user", content: tool_results} if tool_results.any?
1058
+
1059
+ i
1060
+ end
1061
+
1062
+ # Builds a hash mapping tool_use_id → tool_response Message for O(1) lookup.
1063
+ #
1064
+ # @param msgs [Array<Message>]
1065
+ # @return [Hash{String => Message}]
1066
+ def build_tool_response_index(msgs)
1067
+ msgs.each_with_object({}) do |msg, idx|
1068
+ idx[msg.tool_use_id] = msg if msg.message_type == "tool_response"
868
1069
  end
869
1070
  end
870
1071
 
@@ -893,7 +1094,15 @@ class Session < ApplicationRecord
893
1094
  # @example
894
1095
  # format_message_time(1_710_406_260_000_000_000) #=> "Thu Mar 14 09:51"
895
1096
  def format_message_time(timestamp_ns)
896
- Time.at(timestamp_ns / 1_000_000_000.0).strftime("%a %b %-d %H:%M")
1097
+ Time.at(timestamp_ns / 1_000_000_000.0).utc.strftime("%a %b %-d %H:%M")
1098
+ end
1099
+
1100
+ # Current time as nanoseconds since epoch. Uses Time.current so
1101
+ # ActiveSupport's freeze_time works in tests.
1102
+ #
1103
+ # @return [Integer] nanoseconds since epoch
1104
+ def now_ns
1105
+ Time.current.to_ns
897
1106
  end
898
1107
 
899
1108
  # Delegates to {Message#estimate_tokens} for messages not yet counted