anima-core 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +2 -0
  3. data/agents/codebase-analyzer.md +1 -1
  4. data/agents/codebase-pattern-finder.md +1 -1
  5. data/agents/documentation-researcher.md +1 -1
  6. data/agents/thoughts-analyzer.md +1 -1
  7. data/agents/web-search-researcher.md +1 -1
  8. data/app/channels/session_channel.rb +44 -43
  9. data/app/decorators/agent_message_decorator.rb +2 -2
  10. data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
  11. data/app/decorators/system_message_decorator.rb +2 -2
  12. data/app/decorators/tool_call_decorator.rb +2 -2
  13. data/app/decorators/tool_decorator.rb +4 -4
  14. data/app/decorators/tool_response_decorator.rb +2 -2
  15. data/app/decorators/user_message_decorator.rb +3 -3
  16. data/app/decorators/web_get_tool_decorator.rb +41 -9
  17. data/app/jobs/agent_request_job.rb +20 -20
  18. data/app/jobs/count_message_tokens_job.rb +39 -0
  19. data/app/jobs/passive_recall_job.rb +4 -4
  20. data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
  21. data/app/models/goal.rb +4 -4
  22. data/app/models/goal_pinned_message.rb +11 -0
  23. data/app/models/{event.rb → message.rb} +42 -39
  24. data/app/models/pinned_message.rb +41 -0
  25. data/app/models/session.rb +206 -198
  26. data/app/models/snapshot.rb +25 -25
  27. data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
  28. data/lib/agent_loop.rb +6 -6
  29. data/lib/analytical_brain/runner.rb +35 -35
  30. data/lib/analytical_brain/tools/activate_skill.rb +5 -9
  31. data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
  32. data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
  33. data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
  34. data/lib/analytical_brain/tools/finish_goal.rb +5 -8
  35. data/lib/analytical_brain/tools/read_workflow.rb +5 -9
  36. data/lib/analytical_brain/tools/rename_session.rb +3 -10
  37. data/lib/analytical_brain/tools/set_goal.rb +3 -7
  38. data/lib/analytical_brain/tools/update_goal.rb +3 -7
  39. data/lib/anima/settings.rb +15 -4
  40. data/lib/anima/version.rb +1 -1
  41. data/lib/events/bounce_back.rb +7 -7
  42. data/lib/events/subscribers/persister.rb +7 -7
  43. data/lib/events/subscribers/transient_broadcaster.rb +2 -2
  44. data/lib/mneme/compressed_viewport.rb +57 -57
  45. data/lib/mneme/l2_runner.rb +4 -4
  46. data/lib/mneme/passive_recall.rb +2 -2
  47. data/lib/mneme/runner.rb +57 -75
  48. data/lib/mneme/search.rb +38 -38
  49. data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
  50. data/lib/mneme/tools/everything_ok.rb +1 -3
  51. data/lib/mneme/tools/save_snapshot.rb +12 -16
  52. data/lib/tools/bash.rb +4 -12
  53. data/lib/tools/edit.rb +4 -6
  54. data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
  55. data/lib/tools/read.rb +4 -4
  56. data/lib/tools/registry.rb +1 -1
  57. data/lib/tools/remember.rb +46 -55
  58. data/lib/tools/spawn_specialist.rb +12 -23
  59. data/lib/tools/spawn_subagent.rb +9 -19
  60. data/lib/tools/subagent_prompts.rb +0 -2
  61. data/lib/tools/think.rb +3 -10
  62. data/lib/tools/web_get.rb +23 -4
  63. data/lib/tools/write.rb +3 -3
  64. data/lib/tui/cable_client.rb +3 -3
  65. data/lib/tui/message_store.rb +37 -37
  66. data/lib/tui/screens/chat.rb +27 -15
  67. data/skills/activerecord/SKILL.md +1 -1
  68. data/skills/dragonruby/SKILL.md +1 -1
  69. data/skills/draper-decorators/SKILL.md +1 -1
  70. data/skills/gh-issue.md +1 -1
  71. data/skills/mcp-server/SKILL.md +1 -1
  72. data/skills/ratatui-ruby/SKILL.md +1 -1
  73. data/skills/rspec/SKILL.md +1 -1
  74. data/templates/config.toml +16 -5
  75. data/templates/soul.md +7 -19
  76. data/workflows/create_handoff.md +1 -1
  77. data/workflows/create_note.md +1 -1
  78. data/workflows/create_plan.md +1 -1
  79. data/workflows/implement_plan.md +1 -1
  80. data/workflows/iterate_plan.md +1 -1
  81. data/workflows/research_codebase.md +1 -1
  82. data/workflows/resume_handoff.md +1 -1
  83. data/workflows/review_pr.md +78 -16
  84. data/workflows/thoughts_init.md +1 -1
  85. data/workflows/validate_plan.md +1 -1
  86. metadata +10 -9
  87. data/app/jobs/count_event_tokens_job.rb +0 -39
  88. data/app/models/goal_pinned_event.rb +0 -11
  89. data/app/models/pinned_event.rb +0 -41
  90. data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 583896eaa09036e71d6e6b6ee013a021965acc193340ba735e4c0a0a6be6e6dc
4
- data.tar.gz: f1f173f5129c37785a01119daa3cce27a0b935e0fdac256ffb0b412aad87483c
3
+ metadata.gz: f8a12f6624492adc6c4de4e5fbc5e28df74edf73a568b73fc0819a8d3fc45163
4
+ data.tar.gz: 84f3c8680deb81a5e4989abd7b9eeafaae9bdc8e5b0a04db6af27c615f31144c
5
5
  SHA512:
6
- metadata.gz: 312effa8e79b480bd33a78093f192d6a6997fc0430aac6fe09528da0c95461feade771b3ecbcd13c0b7ad036d8a1c4cd2e6a443da61332baf3a8b34c57626ca5
7
- data.tar.gz: 7c3578adb7158f0c32caa7f26ec3ea4d8189502e0bab27b0070f7a98a6423c6d311ddcd429edb7d451dadecd4282b5c7e874b6ce302fccdd1337103034eee1d2
6
+ metadata.gz: ae5c8c7ba47910544c6bb3b316bed1a90a54929cae275983c8dd36fca91cdceebbd06cd50b6a1e512d1142e38ab8ebc414b635dd0ed0ff67eea74c5e0af86d86
7
+ data.tar.gz: 8d8c6ddf84970520c805da2b2fb98d6b2c47faad068f5e602e89389bcc7e70bd206a0ed35dc7dc94aeef2bb8cea615e57627fcf883937d23ad2f70f5610bd6e2
data/.reek.yml CHANGED
@@ -37,6 +37,8 @@ detectors:
37
37
  - "Tools::SpawnSpecialist#execute"
38
38
  # Nickname assignment operates on child session and parent's children — inherent.
39
39
  - "Tools::SubagentPrompts#assign_nickname_via_brain"
40
+ # Goal tools operate on goal objects — inherent to the pattern.
41
+ - "AnalyticalBrain::Tools::UpdateGoal#execute"
40
42
  # Validation methods naturally reference the validated value more than self.
41
43
  - "AnalyticalBrain::Tools::AssignNickname#validate"
42
44
  # Tool execute methods naturally reference input hash and shell result hash.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codebase-analyzer
3
- description: Analyzes codebase implementation details with precise file:line references. Call when you need to understand how specific code works.
3
+ description: Traces data flow and explains how code works. Returns file:line references.
4
4
  tools: read, bash
5
5
  ---
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: codebase-pattern-finder
3
- description: Finds similar implementations, usage examples, and existing patterns that can be modeled after. Returns concrete code examples.
3
+ description: Finds similar implementations, usage examples, and existing patterns to model after. Returns concrete code examples.
4
4
  tools: read, bash
5
5
  ---
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: documentation-researcher
3
- description: Fetches library and framework documentation from the web. Provides setup guides, API usage examples, and implementation patterns tailored to your use case.
3
+ description: Fetches official docs. Returns ready-to-use code examples.
4
4
  tools: web_get, read
5
5
  color: cyan
6
6
  ---
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: thoughts-analyzer
3
- description: Extracts decisions and actionable insights from project history in thoughts/. Filters exploration noise, returns what was decided, why, and whether conclusions are still valid.
3
+ description: "thoughts/ holds design decisions, architecture notes, and implementation rationale. Answers WHY things work the way they do and how they should work by design."
4
4
  tools: read, bash
5
5
  ---
6
6
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: web-search-researcher
3
- description: Deep web research specialist. Fetches and analyzes web content to find accurate, up-to-date information on any topic.
3
+ description: Researches topics across multiple web sources. Use when a single page won't answer the question.
4
4
  tools: web_get, bash, read
5
5
  color: yellow
6
6
  ---
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Streams events for a specific session to connected clients.
4
- # Part of the Brain/TUI separation: the Brain broadcasts events through
3
+ # Streams messages for a specific session to connected clients.
4
+ # Part of the Brain/TUI separation: the Brain broadcasts messages through
5
5
  # this channel, and any number of clients (TUI, web, API) can subscribe.
6
6
  #
7
7
  # On subscription, sends the session's chat history so the client can
@@ -42,10 +42,10 @@ class SessionChannel < ApplicationCable::Channel
42
42
  ActionCable.server.broadcast(stream_name, data)
43
43
  end
44
44
 
45
- # Processes user input. For idle sessions, persists the event immediately
46
- # so the message appears in the TUI without waiting for the background
47
- # job, then schedules {AgentRequestJob} for LLM delivery. If delivery
48
- # fails, the job deletes the event and emits a {Events::BounceBack}.
45
+ # Processes user input. For idle sessions, persists the message immediately
46
+ # so it appears in the TUI without waiting for the background job, then
47
+ # schedules {AgentRequestJob} for LLM delivery. If delivery fails, the
48
+ # job deletes the message and emits a {Events::BounceBack}.
49
49
  #
50
50
  # For busy sessions, emits a pending {Events::UserMessage} that queues
51
51
  # until the current agent loop completes.
@@ -63,23 +63,23 @@ class SessionChannel < ApplicationCable::Channel
63
63
  end
64
64
 
65
65
  # Recalls the most recent pending message for editing. Deletes the
66
- # pending event and broadcasts the recall so all clients remove it.
66
+ # pending message and broadcasts the recall so all clients remove it.
67
67
  #
68
- # @param data [Hash] must include "event_id" (positive integer)
68
+ # @param data [Hash] must include "message_id" (positive integer)
69
69
  def recall_pending(data)
70
- event_id = data["event_id"].to_i
71
- return if event_id <= 0
70
+ message_id = data["message_id"].to_i
71
+ return if message_id <= 0
72
72
 
73
- event = Event.find_by(
74
- id: event_id,
73
+ message = Message.find_by(
74
+ id: message_id,
75
75
  session_id: @current_session_id,
76
- event_type: "user_message",
77
- status: Event::PENDING_STATUS
76
+ message_type: "user_message",
77
+ status: Message::PENDING_STATUS
78
78
  )
79
- return unless event
79
+ return unless message
80
80
 
81
- event.destroy!
82
- ActionCable.server.broadcast(stream_name, {"action" => "user_message_recalled", "event_id" => event_id})
81
+ message.destroy!
82
+ ActionCable.server.broadcast(stream_name, {"action" => "user_message_recalled", "message_id" => message_id})
83
83
  end
84
84
 
85
85
  # Requests interruption of the current tool execution. Sets a flag on the
@@ -106,7 +106,7 @@ class SessionChannel < ApplicationCable::Channel
106
106
  limit = (data["limit"] || DEFAULT_LIST_LIMIT).to_i.clamp(1, MAX_LIST_LIMIT)
107
107
  sessions = Session.root_sessions.recent(limit).includes(:child_sessions)
108
108
  all_ids = sessions.flat_map { |session| [session.id] + session.child_sessions.map(&:id) }
109
- counts = Event.where(session_id: all_ids).llm_messages.group(:session_id).count
109
+ counts = Message.where(session_id: all_ids).llm_messages.group(:session_id).count
110
110
 
111
111
  result = sessions.map { |session| serialize_session_with_children(session, counts) }
112
112
  transmit({"action" => "sessions_list", "sessions" => result})
@@ -196,7 +196,7 @@ class SessionChannel < ApplicationCable::Channel
196
196
  # Used on initial subscription and after session switches so the
197
197
  # client can handle both paths with a single code path.
198
198
  #
199
- # Payload: session_id, name, parent_session_id, message_count,
199
+ # Payload: session_id, name, agent_name, parent_session_id, message_count,
200
200
  # view_mode, active_skills, goals, children (when present).
201
201
  #
202
202
  # @param session [Session] the session to announce
@@ -206,8 +206,9 @@ class SessionChannel < ApplicationCable::Channel
206
206
  "action" => "session_changed",
207
207
  "session_id" => session.id,
208
208
  "name" => session.name,
209
+ "agent_name" => Anima::Settings.agent_name,
209
210
  "parent_session_id" => session.parent_session_id,
210
- "message_count" => session.events.llm_messages.count,
211
+ "message_count" => session.messages.llm_messages.count,
211
212
  "view_mode" => session.view_mode,
212
213
  "active_skills" => session.active_skills,
213
214
  "active_workflow" => session.active_workflow,
@@ -244,22 +245,22 @@ class SessionChannel < ApplicationCable::Channel
244
245
  transmit({"action" => "view_mode", "view_mode" => session.view_mode})
245
246
  end
246
247
 
247
- # Sends decorated context events (messages + tool interactions) from
248
- # the LLM's viewport to the subscribing client. Each event is wrapped
249
- # in an {EventDecorator} and the pre-rendered output is included in
250
- # the transmitted payload. Tool events are included so the TUI can
248
+ # Sends decorated context messages (conversation + tool interactions) from
249
+ # the LLM's viewport to the subscribing client. Each message is wrapped
250
+ # in a {MessageDecorator} and the pre-rendered output is included in
251
+ # the transmitted payload. Tool messages are included so the TUI can
251
252
  # reconstruct tool call counters on reconnect.
252
253
  # In debug mode, prepends the assembled system prompt as a special block.
253
254
  #
254
- # Snapshots the viewport so subsequent event broadcasts can compute
255
+ # Snapshots the viewport so subsequent message broadcasts can compute
255
256
  # eviction diffs accurately.
256
257
  #
257
258
  # @param session [Session] the session whose history to transmit
258
259
  def transmit_history(session)
259
260
  transmit_system_prompt(session) if session.view_mode == "debug"
260
261
 
261
- each_viewport_event(session) do |event, payload|
262
- transmit(payload)
262
+ each_viewport_message(session) do |_msg, msg_payload|
263
+ transmit(msg_payload)
263
264
  end
264
265
  end
265
266
 
@@ -267,7 +268,7 @@ class SessionChannel < ApplicationCable::Channel
267
268
  # Used after a view mode change to refresh all connected clients.
268
269
  # In debug mode, prepends the assembled system prompt as a special block.
269
270
  #
270
- # Snapshots the viewport so subsequent event broadcasts can compute
271
+ # Snapshots the viewport so subsequent message broadcasts can compute
271
272
  # eviction diffs accurately.
272
273
  #
273
274
  # @param session [Session] the session whose viewport to broadcast
@@ -275,40 +276,40 @@ class SessionChannel < ApplicationCable::Channel
275
276
  def broadcast_viewport(session)
276
277
  broadcast_system_prompt(session) if session.view_mode == "debug"
277
278
 
278
- each_viewport_event(session) do |event, payload|
279
- ActionCable.server.broadcast(stream_name, payload)
279
+ each_viewport_message(session) do |_msg, msg_payload|
280
+ ActionCable.server.broadcast(stream_name, msg_payload)
280
281
  end
281
282
  end
282
283
 
283
284
  # Loads the viewport, snapshots it for eviction tracking, and yields
284
- # each event with its decorated payload. Snapshot uses snapshot_viewport!
285
+ # each message with its decorated payload. Snapshot uses snapshot_viewport!
285
286
  # (not recalculate_viewport!) because full viewport refreshes don't need
286
287
  # eviction diffs — clients clear their store before rendering.
287
288
  #
288
289
  # @param session [Session] the session whose viewport to iterate
289
- # @yieldparam event [Event] the persisted event record
290
+ # @yieldparam message [Message] the persisted message record
290
291
  # @yieldparam payload [Hash] decorated payload ready for transmission
291
292
  # @return [void]
292
- def each_viewport_event(session)
293
- viewport = session.viewport_events
293
+ def each_viewport_message(session)
294
+ viewport = session.viewport_messages
294
295
  session.snapshot_viewport!(viewport.map(&:id))
295
296
 
296
- viewport.each do |event|
297
- yield event, decorate_event_payload(event, session.view_mode)
297
+ viewport.each do |msg|
298
+ yield msg, decorate_message_payload(msg, session.view_mode)
298
299
  end
299
300
  end
300
301
 
301
- # Decorates an event for transmission to clients. Merges the event's
302
+ # Decorates a message for transmission to clients. Merges the message's
302
303
  # database ID and structured decorator output into the payload.
303
304
  # Used by {#transmit_history} and {#broadcast_viewport} for historical
304
- # and viewport re-broadcast — live broadcasts use {Event::Broadcasting}.
305
+ # and viewport re-broadcast — live broadcasts use {Message::Broadcasting}.
305
306
  #
306
- # @param event [Event] persisted event record
307
+ # @param message [Message] persisted message record
307
308
  # @param mode [String] view mode for decoration (default: "basic")
308
309
  # @return [Hash] payload with "id" and optional "rendered" key
309
- def decorate_event_payload(event, mode = "basic")
310
- payload = event.payload.merge("id" => event.id)
311
- decorator = EventDecorator.for(event)
310
+ def decorate_message_payload(message, mode = "basic")
311
+ payload = message.payload.merge("id" => message.id)
312
+ decorator = MessageDecorator.for(message)
312
313
  return payload unless decorator
313
314
 
314
315
  payload.merge("rendered" => {mode => decorator.render(mode)})
@@ -343,7 +344,7 @@ class SessionChannel < ApplicationCable::Channel
343
344
  prompt = session.system_prompt
344
345
  return unless prompt
345
346
 
346
- tokens = [(prompt.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
347
+ tokens = [(prompt.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
347
348
  {
348
349
  "type" => "system_prompt",
349
350
  "rendered" => {
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Decorates agent_message events for display in the TUI.
3
+ # Decorates agent_message records for display in the TUI.
4
4
  # Basic mode returns role and content. Verbose mode adds a timestamp.
5
5
  # Debug mode adds token count (exact when counted, estimated when not).
6
- class AgentMessageDecorator < EventDecorator
6
+ class AgentMessageDecorator < MessageDecorator
7
7
  # @return [Hash] structured agent message data
8
8
  # `{role: :assistant, content: String}`
9
9
  def render_basic
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Base decorator for {Event} records, providing multi-resolution rendering
4
- # for the TUI and analytical brain. Each event type has a dedicated subclass
3
+ # Base decorator for {Message} records, providing multi-resolution rendering
4
+ # for the TUI and analytical brain. Each message type has a dedicated subclass
5
5
  # that implements rendering methods for each view mode:
6
6
  #
7
7
  # - **basic** / **verbose** / **debug** — TUI display modes returning structured hashes
@@ -13,23 +13,23 @@
13
13
  # and formats it for display.
14
14
  #
15
15
  # Brain mode returns condensed single-line strings for the analytical brain's
16
- # event transcript. Returns nil to exclude an event from the brain's view.
16
+ # message transcript. Returns nil to exclude a message from the brain's view.
17
17
  #
18
18
  # Subclasses must override {#render_basic}. Verbose, debug, and brain modes
19
19
  # delegate to basic until subclasses provide their own implementations.
20
20
  #
21
- # @example Decorate an Event AR model
22
- # decorator = EventDecorator.for(event)
21
+ # @example Decorate a Message AR model
22
+ # decorator = MessageDecorator.for(message)
23
23
  # decorator.render_basic #=> {role: :user, content: "hello"} or nil
24
24
  #
25
25
  # @example Render for a specific view mode
26
- # decorator = EventDecorator.for(event)
26
+ # decorator = MessageDecorator.for(message)
27
27
  # decorator.render("verbose") #=> {role: :user, content: "hello", timestamp: 1709312325000000000}
28
28
  #
29
29
  # @example Decorate a raw payload hash (from EventBus)
30
- # decorator = EventDecorator.for(type: "user_message", content: "hello")
30
+ # decorator = MessageDecorator.for(type: "user_message", content: "hello")
31
31
  # decorator.render_basic #=> {role: :user, content: "hello"}
32
- class EventDecorator < ApplicationDecorator
32
+ class MessageDecorator < ApplicationDecorator
33
33
  delegate_all
34
34
 
35
35
  TOOL_ICON = "\u{1F527}"
@@ -46,36 +46,36 @@ class EventDecorator < ApplicationDecorator
46
46
  }.freeze
47
47
  private_constant :DECORATOR_MAP
48
48
 
49
- # Normalizes hash payloads into an Event-like interface so decorators
50
- # can use {#payload}, {#event_type}, etc. uniformly on both AR models
49
+ # Normalizes hash payloads into a Message-like interface so decorators
50
+ # can use {#payload}, {#message_type}, etc. uniformly on both AR models
51
51
  # and raw EventBus hashes.
52
52
  #
53
- # @!attribute event_type [r] the event's type (e.g. "user_message")
54
- # @!attribute payload [r] string-keyed hash of event data
53
+ # @!attribute message_type [r] the message's type (e.g. "user_message")
54
+ # @!attribute payload [r] string-keyed hash of message data
55
55
  # @!attribute timestamp [r] nanosecond-precision timestamp
56
56
  # @!attribute token_count [r] cumulative token count
57
- EventPayload = Struct.new(:event_type, :payload, :timestamp, :token_count, keyword_init: true) do
58
- # Heuristic token estimate matching {Event#estimate_tokens} so decorators
57
+ MessagePayload = Struct.new(:message_type, :payload, :timestamp, :token_count, keyword_init: true) do
58
+ # Heuristic token estimate matching {Message#estimate_tokens} so decorators
59
59
  # can call it uniformly on both AR models and hash payloads.
60
60
  # @return [Integer] at least 1
61
61
  def estimate_tokens
62
- text = if event_type.to_s.in?(%w[tool_call tool_response])
62
+ text = if message_type.to_s.in?(%w[tool_call tool_response])
63
63
  payload.to_json
64
64
  else
65
65
  payload&.dig("content").to_s
66
66
  end
67
- [(text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
67
+ [(text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
68
68
  end
69
69
  end
70
70
 
71
- # Factory returning the appropriate subclass decorator for the given event.
72
- # Hashes are normalized via {EventPayload} to provide a uniform interface.
71
+ # Factory returning the appropriate subclass decorator for the given message.
72
+ # Hashes are normalized via {MessagePayload} to provide a uniform interface.
73
73
  #
74
- # @param event [Event, Hash] an Event AR model or a raw payload hash
75
- # @return [EventDecorator, nil] decorated event, or nil for unknown types
76
- def self.for(event)
77
- source = wrap_source(event)
78
- klass_name = DECORATOR_MAP[source.event_type]
74
+ # @param message [Message, Hash] a Message AR model or a raw payload hash
75
+ # @return [MessageDecorator, nil] decorated message, or nil for unknown types
76
+ def self.for(message)
77
+ source = wrap_source(message)
78
+ klass_name = DECORATOR_MAP[source.message_type]
79
79
  return nil unless klass_name
80
80
 
81
81
  klass_name.constantize.new(source)
@@ -92,8 +92,8 @@ class EventDecorator < ApplicationDecorator
92
92
  # Dispatches to the render method for the given view mode.
93
93
  #
94
94
  # @param mode [String] one of "basic", "verbose", "debug", "brain"
95
- # @return [Hash, String, nil] structured event data (basic/verbose/debug),
96
- # plain string (brain), or nil to hide the event
95
+ # @return [Hash, String, nil] structured message data (basic/verbose/debug),
96
+ # plain string (brain), or nil to hide the message
97
97
  # @raise [ArgumentError] if the mode is not a valid view mode
98
98
  def render(mode)
99
99
  method = RENDER_DISPATCH[mode]
@@ -102,29 +102,29 @@ class EventDecorator < ApplicationDecorator
102
102
  public_send(method)
103
103
  end
104
104
 
105
- # @abstract Subclasses must implement to render the event for basic view mode.
106
- # @return [Hash, nil] structured event data, or nil to hide the event
105
+ # @abstract Subclasses must implement to render the message for basic view mode.
106
+ # @return [Hash, nil] structured message data, or nil to hide the message
107
107
  def render_basic
108
108
  raise NotImplementedError, "#{self.class} must implement #render_basic"
109
109
  end
110
110
 
111
111
  # Verbose view mode with timestamps and tool details.
112
112
  # Delegates to {#render_basic} until subclasses provide their own implementations.
113
- # @return [Hash, nil] structured event data, or nil to hide the event
113
+ # @return [Hash, nil] structured message data, or nil to hide the message
114
114
  def render_verbose
115
115
  render_basic
116
116
  end
117
117
 
118
118
  # Debug view mode with token counts and system prompts.
119
119
  # Delegates to {#render_basic} until subclasses provide their own implementations.
120
- # @return [Hash, nil] structured event data, or nil to hide the event
120
+ # @return [Hash, nil] structured message data, or nil to hide the message
121
121
  def render_debug
122
122
  render_basic
123
123
  end
124
124
 
125
125
  # Analytical brain view — condensed single-line string for the brain's
126
- # event transcript. Returns nil to exclude from the brain's context.
127
- # Subclasses override to provide event-type-specific formatting.
126
+ # message transcript. Returns nil to exclude from the brain's context.
127
+ # Subclasses override to provide message-type-specific formatting.
128
128
  # @return [String, nil] formatted transcript line, or nil to skip
129
129
  def render_brain
130
130
  nil
@@ -132,7 +132,7 @@ class EventDecorator < ApplicationDecorator
132
132
 
133
133
  private
134
134
 
135
- # Token count for display: exact count from {CountEventTokensJob} when
135
+ # Token count for display: exact count from {CountMessageTokensJob} when
136
136
  # available, heuristic estimate otherwise. Estimated counts are flagged
137
137
  # so the TUI can prefix them with a tilde.
138
138
  #
@@ -147,14 +147,14 @@ class EventDecorator < ApplicationDecorator
147
147
  end
148
148
 
149
149
  # Delegates to the underlying object's heuristic token estimator.
150
- # Both {Event} AR models and {EventPayload} structs implement this.
150
+ # Both {Message} AR models and {MessagePayload} structs implement this.
151
151
  #
152
152
  # @return [Integer] at least 1
153
153
  def estimate_token_count
154
154
  object.estimate_tokens
155
155
  end
156
156
 
157
- # Extracts display content from the event payload.
157
+ # Extracts display content from the message payload.
158
158
  # @return [String, nil]
159
159
  def content
160
160
  payload["content"]
@@ -190,14 +190,14 @@ class EventDecorator < ApplicationDecorator
190
190
  end
191
191
 
192
192
  # Normalizes input to something Draper can wrap.
193
- # Event AR models pass through; hashes become EventPayload structs
193
+ # Message AR models pass through; hashes become MessagePayload structs
194
194
  # with string-normalized keys.
195
- def self.wrap_source(event)
196
- return event unless event.is_a?(Hash)
195
+ def self.wrap_source(message)
196
+ return message unless message.is_a?(Hash)
197
197
 
198
- normalized = event.transform_keys(&:to_s)
199
- EventPayload.new(
200
- event_type: normalized["type"].to_s,
198
+ normalized = message.transform_keys(&:to_s)
199
+ MessagePayload.new(
200
+ message_type: normalized["type"].to_s,
201
201
  payload: normalized,
202
202
  timestamp: normalized["timestamp"],
203
203
  token_count: normalized["token_count"]&.to_i || 0
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Decorates system_message events for display in the TUI.
3
+ # Decorates system_message records for display in the TUI.
4
4
  # Hidden in basic mode. Verbose and debug modes return timestamped system info.
5
- class SystemMessageDecorator < EventDecorator
5
+ class SystemMessageDecorator < MessageDecorator
6
6
  # @return [nil] system messages are hidden in basic mode
7
7
  def render_basic
8
8
  nil
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "toon"
4
4
 
5
- # Decorates tool_call events for display in the TUI.
5
+ # Decorates tool_call records for display in the TUI.
6
6
  # Hidden in basic mode — tool activity is represented by the
7
7
  # aggregated tool counter instead. Verbose mode returns tool name
8
8
  # and a formatted preview of the input arguments. Debug mode shows
@@ -12,7 +12,7 @@ require "toon"
12
12
  # Think tool calls are special: "aloud" thoughts are shown in all
13
13
  # view modes (with a thought bubble), while "inner" thoughts are
14
14
  # visible only in verbose and debug modes.
15
- class ToolCallDecorator < EventDecorator
15
+ class ToolCallDecorator < MessageDecorator
16
16
  THINK_TOOL = "think"
17
17
 
18
18
  # In basic mode, only "aloud" think calls are visible.
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Base class for server-side tool response decoration. Transforms raw tool
4
- # results into LLM-optimized formats before they enter the event stream.
4
+ # results into LLM-optimized formats before they enter the message stream.
5
5
  #
6
- # This is a separate decorator type from {EventDecorator}: EventDecorator
7
- # formats events for clients (TUI/web), while ToolDecorator formats tool
6
+ # This is a separate decorator type from {MessageDecorator}: MessageDecorator
7
+ # formats messages for clients (TUI/web), while ToolDecorator formats tool
8
8
  # responses for the LLM. They sit at different points in the pipeline:
9
9
  #
10
- # Tool executes → ToolDecorator transforms → event stream → EventDecorator renders
10
+ # Tool executes → ToolDecorator transforms → message stream → MessageDecorator renders
11
11
  #
12
12
  # Subclasses implement {#call} to transform a tool's raw result into an
13
13
  # LLM-friendly string. Each tool can have its own ToolDecorator subclass
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Decorates tool_response events for display in the TUI.
3
+ # Decorates tool_response records for display in the TUI.
4
4
  # Hidden in basic mode — tool activity is represented by the
5
5
  # aggregated tool counter instead. Verbose mode returns truncated
6
6
  # output with a success/failure indicator and tool name for per-tool
@@ -9,7 +9,7 @@
9
9
  #
10
10
  # Think tool responses ("OK") are hidden in basic and verbose modes
11
11
  # because the value is in the tool_call (the thoughts), not the response.
12
- class ToolResponseDecorator < EventDecorator
12
+ class ToolResponseDecorator < MessageDecorator
13
13
  THINK_TOOL = "think"
14
14
 
15
15
  # @return [nil] tool responses are hidden in basic mode
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Decorates user_message events for display in the TUI.
3
+ # Decorates user_message records for display in the TUI.
4
4
  # Basic mode returns role and content. Verbose mode adds a timestamp.
5
5
  # Debug mode adds token count (exact when counted, estimated when not).
6
6
  # Pending messages include `status: "pending"` so the TUI renders them
7
7
  # with a visual indicator (dimmed, clock icon).
8
- class UserMessageDecorator < EventDecorator
8
+ class UserMessageDecorator < MessageDecorator
9
9
  # @return [Hash] structured user message data
10
10
  # `{role: :user, content: String}` or with `status: "pending"` when queued
11
11
  def render_basic
@@ -36,6 +36,6 @@ class UserMessageDecorator < EventDecorator
36
36
 
37
37
  # @return [Boolean] true when this message is queued but not yet sent to LLM
38
38
  def pending?
39
- payload["status"] == Event::PENDING_STATUS
39
+ payload["status"] == Message::PENDING_STATUS
40
40
  end
41
41
  end
@@ -19,9 +19,14 @@ require "toon"
19
19
  # decorator.call(body: "<h1>Hi</h1>", content_type: "text/html")
20
20
  # #=> "[Converted: HTML → Markdown]\n\n# Hi"
21
21
  class WebGetToolDecorator < ToolDecorator
22
- # HTML elements that carry no useful content for an LLM.
23
- NOISE_TAGS = %w[script style nav footer aside form noscript iframe
24
- svg header menu menuitem].freeze
22
+ # Tags that never contain readable content always removed.
23
+ RENDER_TAGS = %w[script style noscript iframe svg].freeze
24
+
25
+ # Structural elements stripped only when no semantic content container is found.
26
+ STRUCTURAL_TAGS = %w[nav footer aside form header menu menuitem].freeze
27
+
28
+ # Semantic HTML5 containers in preference order (first match wins).
29
+ CONTENT_SELECTORS = ["main", "article", "[role='main']"].freeze
25
30
 
26
31
  # @param result [Hash] `{body: String, content_type: String}`
27
32
  # @return [String] LLM-optimized content with conversion metadata tag
@@ -57,14 +62,19 @@ class WebGetToolDecorator < ToolDecorator
57
62
  {text: body, meta: nil}
58
63
  end
59
64
 
60
- # Strips noise elements (scripts, styles, nav, ads) and converts
61
- # semantic HTML to Markdown for clean LLM consumption.
65
+ # Strips noise elements and converts semantic HTML to Markdown.
66
+ # Warns when the extracted content is suspiciously short.
62
67
  #
63
68
  # @param body [String] HTML response body
64
69
  # @return [Hash] `{text: String, meta: String}`
65
70
  def text_html(body)
66
71
  markdown = html_to_markdown(body)
67
- {text: markdown, meta: "[Converted: HTML → Markdown]"}
72
+ meta = "[Converted: HTML → Markdown]"
73
+ char_count = markdown.length
74
+ if !body.empty? && char_count < Anima::Settings.min_web_content_chars
75
+ meta += " [Warning: only #{char_count} chars extracted — content may be incomplete]"
76
+ end
77
+ {text: markdown, meta: meta}
68
78
  end
69
79
 
70
80
  # Passthrough for unregistered content types.
@@ -80,18 +90,40 @@ class WebGetToolDecorator < ToolDecorator
80
90
 
81
91
  private
82
92
 
83
- # Strips noise HTML elements then converts to Markdown.
93
+ # Converts HTML to Markdown using content-aware extraction.
94
+ #
95
+ # Prefers semantic containers (+<main>+, +<article>+, +[role="main"]+)
96
+ # when available. Falls back to stripping structural noise from the
97
+ # +<body>+. Rendering artifacts (scripts, styles, iframes, SVGs) are
98
+ # always removed.
84
99
  #
85
100
  # @param html [String] raw HTML
86
101
  # @return [String] clean Markdown
87
102
  def html_to_markdown(html)
88
103
  doc = Nokogiri::HTML(html)
89
- doc.css(NOISE_TAGS.join(", ")).remove
90
- clean_html = doc.at("body")&.inner_html || doc.to_html
104
+ doc.css(RENDER_TAGS.join(", ")).remove
105
+
106
+ clean_html = extract_content(doc)
91
107
  markdown = ReverseMarkdown.convert(clean_html, unknown_tags: :bypass, github_flavored: true)
92
108
  collapse_whitespace(markdown)
93
109
  end
94
110
 
111
+ # Extracts the primary content from a parsed HTML document.
112
+ #
113
+ # Prefers semantic containers ({CONTENT_SELECTORS}) and returns the first
114
+ # match. When none exist, strips {STRUCTURAL_TAGS} from the +<body>+ and
115
+ # returns what remains.
116
+ #
117
+ # @param doc [Nokogiri::HTML::Document]
118
+ # @return [String] inner HTML of the best content node
119
+ def extract_content(doc)
120
+ content = CONTENT_SELECTORS.lazy.filter_map { |sel| doc.at_css(sel) }.first
121
+ return content.inner_html if content
122
+
123
+ doc.css(STRUCTURAL_TAGS.join(", ")).remove
124
+ doc.at("body")&.inner_html || doc.to_html
125
+ end
126
+
95
127
  # Collapses excessive blank lines down to a single blank line.
96
128
  #
97
129
  # @param text [String]