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
- # 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,17 +2,17 @@
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
9
9
  # full untruncated input with tool_use_id — TOON format for most
10
- # tools, but write tool content preserves actual newlines.
10
+ # tools, but write_file tool content preserves actual newlines.
11
11
  #
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.
@@ -97,14 +97,14 @@ class ToolCallDecorator < EventDecorator
97
97
  def format_debug_input
98
98
  input = tool_input
99
99
  case payload["tool_name"]
100
- when "write" then format_write_content(input)
100
+ when "write_file" then format_write_content(input)
101
101
  else Toon.encode(input)
102
102
  end
103
103
  end
104
104
 
105
105
  # Formats write tool input with file path header and content body.
106
106
  # Content newlines are preserved so the TUI can render them as
107
- # separate lines, matching how read tool responses display file content.
107
+ # separate lines, matching how read_file tool responses display file content.
108
108
  # @param input [Hash] tool input hash with "file_path" and "content" keys
109
109
  # @return [String] path + content with real newlines, or TOON-encoded hash when content is empty
110
110
  def format_write_content(input)
@@ -125,7 +125,7 @@ class ToolCallDecorator < EventDecorator
125
125
  "$ #{input&.dig("command")}"
126
126
  when "web_get"
127
127
  "GET #{input&.dig("url")}"
128
- when "read", "edit", "write"
128
+ when "read_file", "edit_file", "write_file"
129
129
  input&.dig("file_path").to_s
130
130
  else
131
131
  truncate_lines(Toon.encode(input), max_lines: 2)
@@ -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,24 +1,17 @@
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
- # Pending messages include `status: "pending"` so the TUI renders them
7
- # with a visual indicator (dimmed, clock icon).
8
- class UserMessageDecorator < EventDecorator
9
- # @return [Hash] structured user message data
10
- # `{role: :user, content: String}` or with `status: "pending"` when queued
6
+ class UserMessageDecorator < MessageDecorator
7
+ # @return [Hash] structured user message data `{role: :user, content: String}`
11
8
  def render_basic
12
- base = {role: :user, content: content}
13
- base[:status] = "pending" if pending?
14
- base
9
+ {role: :user, content: content}
15
10
  end
16
11
 
17
12
  # @return [Hash] structured user message with nanosecond timestamp
18
13
  def render_verbose
19
- base = {role: :user, content: content, timestamp: timestamp}
20
- base[:status] = "pending" if pending?
21
- base
14
+ {role: :user, content: content, timestamp: timestamp}
22
15
  end
23
16
 
24
17
  # @return [Hash] verbose output plus token count for debugging
@@ -31,11 +24,4 @@ class UserMessageDecorator < EventDecorator
31
24
  def render_brain
32
25
  "User: #{truncate_middle(content)}"
33
26
  end
34
-
35
- private
36
-
37
- # @return [Boolean] true when this message is queued but not yet sent to LLM
38
- def pending?
39
- payload["status"] == Event::PENDING_STATUS
40
- end
41
27
  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]
@@ -5,18 +5,18 @@
5
5
  #
6
6
  # Supports two modes:
7
7
  #
8
- # **Immediate Persist (event_id provided):** The user event was already
8
+ # **Immediate Persist (message_id provided):** The user message was already
9
9
  # persisted and broadcast by the caller (e.g. {SessionChannel#speak}).
10
10
  # The job verifies LLM delivery — if the first API call fails, the
11
- # event is deleted and a {Events::BounceBack} is emitted so clients
11
+ # message is deleted and a {Events::BounceBack} is emitted so clients
12
12
  # can restore the text to the input field.
13
13
  #
14
- # **Standard (no event_id):** Processes already-persisted events (e.g.
14
+ # **Standard (no message_id):** Processes already-persisted messages (e.g.
15
15
  # after pending message promotion). Uses ActiveJob retry/discard for
16
16
  # error handling.
17
17
  #
18
- # @example Immediate Persist — event already saved by SessionChannel
19
- # AgentRequestJob.perform_later(session.id, event_id: 42)
18
+ # @example Immediate Persist — message already saved by SessionChannel
19
+ # AgentRequestJob.perform_later(session.id, message_id: 42)
20
20
  #
21
21
  # @example Standard — pending message processing
22
22
  # AgentRequestJob.perform_later(session.id)
@@ -49,8 +49,8 @@ class AgentRequestJob < ApplicationJob
49
49
  end
50
50
 
51
51
  # @param session_id [Integer] ID of the session to process
52
- # @param event_id [Integer, nil] ID of a pre-persisted user event (triggers delivery verification)
53
- def perform(session_id, event_id: nil)
52
+ # @param message_id [Integer, nil] ID of a pre-persisted user message (triggers delivery verification)
53
+ def perform(session_id, message_id: nil)
54
54
  session = Session.find(session_id)
55
55
 
56
56
  # Atomic: only one job processes a session at a time.
@@ -60,8 +60,8 @@ class AgentRequestJob < ApplicationJob
60
60
 
61
61
  agent_loop = AgentLoop.new(session: session)
62
62
 
63
- if event_id
64
- deliver_persisted_event(session, event_id, agent_loop)
63
+ if message_id
64
+ deliver_persisted_message(session, message_id, agent_loop)
65
65
  else
66
66
  agent_loop.run
67
67
  end
@@ -82,11 +82,11 @@ class AgentRequestJob < ApplicationJob
82
82
 
83
83
  private
84
84
 
85
- # Verifies LLM delivery for a pre-persisted user event.
85
+ # Verifies LLM delivery for a pre-persisted user message.
86
86
  #
87
- # The event was already created and broadcast by the caller, so
87
+ # The message was already created and broadcast by the caller, so
88
88
  # the user sees their message immediately. This method makes the
89
- # first LLM API call — if it fails, the event is deleted and a
89
+ # first LLM API call — if it fails, the message is deleted and a
90
90
  # {Events::BounceBack} notifies clients to remove the phantom
91
91
  # message and restore the text to the input field. For
92
92
  # {Providers::Anthropic::AuthenticationError}, an additional
@@ -102,26 +102,26 @@ class AgentRequestJob < ApplicationJob
102
102
  # execution, subsequent API calls).
103
103
  #
104
104
  # @param session [Session] the conversation session
105
- # @param event_id [Integer] database ID of the pre-persisted user event
105
+ # @param message_id [Integer] database ID of the pre-persisted user message
106
106
  # @param agent_loop [AgentLoop] agent loop instance (reused for continuation)
107
- def deliver_persisted_event(session, event_id, agent_loop)
108
- event = Event.find_by(id: event_id, session_id: session.id)
109
- # Event may have been deleted between SessionChannel#speak and job
107
+ def deliver_persisted_message(session, message_id, agent_loop)
108
+ message = Message.find_by(id: message_id, session_id: session.id)
109
+ # Message may have been deleted between SessionChannel#speak and job
110
110
  # execution (e.g. user recalled the message). Exit silently — there
111
111
  # is nothing to deliver or bounce back.
112
- return unless event
112
+ return unless message
113
113
 
114
- content = event.payload["content"]
114
+ content = message.payload["content"]
115
115
 
116
116
  begin
117
117
  agent_loop.deliver!
118
118
  rescue => error
119
- event.destroy!
119
+ message.destroy!
120
120
  Events::Bus.emit(Events::BounceBack.new(
121
121
  content: content,
122
122
  error: error.message,
123
123
  session_id: session.id,
124
- event_id: event_id
124
+ message_id: message_id
125
125
  ))
126
126
  broadcast_auth_required(session.id, error) if error.is_a?(Providers::Anthropic::AuthenticationError)
127
127
  return
@@ -155,18 +155,27 @@ class AgentRequestJob < ApplicationJob
155
155
 
156
156
  # Sets the session's processing flag atomically. Returns true if this
157
157
  # job claimed the lock, false if another job already holds it.
158
- # Broadcasts the state change to the parent session's HUD.
158
+ # Broadcasts +session_state: llm_generating+ and the state change to
159
+ # the parent session's HUD.
159
160
  def claim_processing(session_id)
160
161
  claimed = Session.where(id: session_id, processing: false).update_all(processing: true) == 1
161
- Session.find_by(id: session_id)&.broadcast_children_update_to_parent if claimed
162
+ if claimed
163
+ session = Session.find_by(id: session_id)
164
+ session&.broadcast_session_state("llm_generating")
165
+ session&.broadcast_children_update_to_parent
166
+ end
162
167
  claimed
163
168
  end
164
169
 
165
170
  # Clears the processing flag so the session can accept new jobs.
166
- # Broadcasts the state change to the parent session's HUD.
171
+ # Broadcasts +session_state: idle+ to the session stream (replaces
172
+ # the old +processing_stopped+ action) and +children_updated+ to the
173
+ # parent session's HUD.
167
174
  def release_processing(session_id)
168
175
  Session.where(id: session_id).update_all(processing: false)
169
- Session.find_by(id: session_id)&.broadcast_children_update_to_parent
176
+ session = Session.find_by(id: session_id)
177
+ session&.broadcast_session_state("idle")
178
+ session&.broadcast_children_update_to_parent
170
179
  end
171
180
 
172
181
  # Safety-net clearing of the interrupt flag.
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Counts tokens in a message's payload via the Anthropic API and
4
+ # caches the result on the message record. Enqueued automatically
5
+ # after each LLM message is created.
6
+ class CountMessageTokensJob < ApplicationJob
7
+ queue_as :default
8
+
9
+ retry_on Providers::Anthropic::Error, wait: :polynomially_longer, attempts: 3
10
+ discard_on ActiveRecord::RecordNotFound
11
+
12
+ # @param message_id [Integer] the Message record to count tokens for
13
+ def perform(message_id)
14
+ message = Message.find(message_id)
15
+ return if already_counted?(message)
16
+
17
+ provider = Providers::Anthropic.new
18
+ api_messages = [{role: message.api_role, content: message.payload["content"].to_s}]
19
+
20
+ token_count = provider.count_tokens(
21
+ model: Anima::Settings.model,
22
+ messages: api_messages
23
+ )
24
+
25
+ # Guard against parallel jobs: reload and re-check before writing.
26
+ # Uses update! (not update_all) so {Message::Broadcasting} after_update_commit
27
+ # broadcasts the updated token count to connected clients.
28
+ message.reload
29
+ return if already_counted?(message)
30
+
31
+ message.update!(token_count: token_count)
32
+ end
33
+
34
+ private
35
+
36
+ def already_counted?(message)
37
+ message.token_count > 0
38
+ end
39
+ end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Runs passive recall after goal updates — searches event history for
3
+ # Runs passive recall after goal updates — searches message history for
4
4
  # context relevant to active goals and caches results on the session
5
5
  # for viewport injection.
6
6
  #
@@ -20,10 +20,10 @@ class PassiveRecallJob < ApplicationJob
20
20
  results = Mneme::PassiveRecall.new(session).call
21
21
 
22
22
  if results.any?
23
- session.update_column(:recalled_event_ids, results.map(&:event_id))
23
+ session.update_column(:recalled_message_ids, results.map(&:message_id))
24
24
  Mneme.logger.info("session=#{session_id} — passive recall found #{results.size} memories")
25
- elsif session.recalled_event_ids.present?
26
- session.update_column(:recalled_event_ids, [])
25
+ elsif session.recalled_message_ids.present?
26
+ session.update_column(:recalled_message_ids, [])
27
27
  end
28
28
  end
29
29
  end