anima-core 1.0.1 → 1.1.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +61 -0
  4. data/README.md +202 -116
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +44 -10
  7. data/app/decorators/agent_message_decorator.rb +6 -0
  8. data/app/decorators/event_decorator.rb +41 -7
  9. data/app/decorators/tool_call_decorator.rb +66 -5
  10. data/app/decorators/tool_decorator.rb +57 -0
  11. data/app/decorators/tool_response_decorator.rb +35 -5
  12. data/app/decorators/user_message_decorator.rb +6 -0
  13. data/app/decorators/web_get_tool_decorator.rb +102 -0
  14. data/app/jobs/agent_request_job.rb +95 -20
  15. data/app/jobs/mneme_job.rb +51 -0
  16. data/app/jobs/passive_recall_job.rb +29 -0
  17. data/app/models/concerns/event/broadcasting.rb +18 -0
  18. data/app/models/event.rb +10 -0
  19. data/app/models/goal.rb +27 -0
  20. data/app/models/goal_pinned_event.rb +11 -0
  21. data/app/models/pinned_event.rb +41 -0
  22. data/app/models/session.rb +335 -6
  23. data/app/models/snapshot.rb +76 -0
  24. data/config/initializers/event_subscribers.rb +14 -3
  25. data/config/initializers/fts5_schema_dump.rb +21 -0
  26. data/db/migrate/20260316094817_add_interrupt_requested_to_sessions.rb +5 -0
  27. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  28. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  29. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  30. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  31. data/lib/agent_loop.rb +67 -18
  32. data/lib/analytical_brain/runner.rb +159 -84
  33. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  34. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  35. data/lib/anima/cli.rb +34 -1
  36. data/lib/anima/config_migrator.rb +205 -0
  37. data/lib/anima/installer.rb +13 -130
  38. data/lib/anima/settings.rb +42 -1
  39. data/lib/anima/version.rb +1 -1
  40. data/lib/events/bounce_back.rb +37 -0
  41. data/lib/events/subscribers/agent_dispatcher.rb +29 -0
  42. data/lib/events/subscribers/persister.rb +17 -0
  43. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  44. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  45. data/lib/llm/client.rb +99 -14
  46. data/lib/mneme/compressed_viewport.rb +200 -0
  47. data/lib/mneme/l2_runner.rb +138 -0
  48. data/lib/mneme/passive_recall.rb +69 -0
  49. data/lib/mneme/runner.rb +254 -0
  50. data/lib/mneme/search.rb +150 -0
  51. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  52. data/lib/mneme/tools/everything_ok.rb +24 -0
  53. data/lib/mneme/tools/save_snapshot.rb +68 -0
  54. data/lib/mneme.rb +29 -0
  55. data/lib/providers/anthropic.rb +57 -13
  56. data/lib/shell_session.rb +188 -59
  57. data/lib/tasks/fts5.rake +6 -0
  58. data/lib/tools/remember.rb +179 -0
  59. data/lib/tools/spawn_specialist.rb +21 -9
  60. data/lib/tools/spawn_subagent.rb +22 -11
  61. data/lib/tools/subagent_prompts.rb +20 -3
  62. data/lib/tools/think.rb +57 -0
  63. data/lib/tools/web_get.rb +15 -6
  64. data/lib/tui/app.rb +230 -127
  65. data/lib/tui/cable_client.rb +8 -0
  66. data/lib/tui/decorators/base_decorator.rb +165 -0
  67. data/lib/tui/decorators/bash_decorator.rb +20 -0
  68. data/lib/tui/decorators/edit_decorator.rb +19 -0
  69. data/lib/tui/decorators/read_decorator.rb +24 -0
  70. data/lib/tui/decorators/think_decorator.rb +36 -0
  71. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  72. data/lib/tui/decorators/write_decorator.rb +19 -0
  73. data/lib/tui/flash.rb +139 -0
  74. data/lib/tui/formatting.rb +28 -0
  75. data/lib/tui/height_map.rb +93 -0
  76. data/lib/tui/message_store.rb +25 -1
  77. data/lib/tui/performance_logger.rb +90 -0
  78. data/lib/tui/screens/chat.rb +374 -109
  79. data/templates/config.toml +156 -0
  80. metadata +87 -4
  81. data/CHANGELOG.md +0 -79
  82. data/Gemfile +0 -17
  83. data/lib/tools/return_result.rb +0 -81
data/anima-core.gemspec CHANGED
@@ -21,13 +21,14 @@ Gem::Specification.new do |spec|
21
21
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
22
22
  spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
23
23
  ls.readlines("\x0", chomp: true).reject do |f|
24
- f.start_with?(*%w[bin/console bin/dev bin/setup .gitignore .rspec spec/ .github/ .standard.yml thoughts/ CLAUDE.md .mise.toml])
24
+ f.start_with?(*%w[bin/console bin/dev bin/setup Gemfile .gitignore .rspec spec/ .github/ .standard.yml thoughts/ CLAUDE.md .mise.toml])
25
25
  end
26
26
  end
27
27
  spec.bindir = "exe"
28
28
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
29
29
  spec.require_paths = ["lib"]
30
30
 
31
+ spec.add_dependency "certifi"
31
32
  spec.add_dependency "draper", "~> 4.0"
32
33
  spec.add_dependency "faraday", "~> 2.0"
33
34
  spec.add_dependency "foreman", "~> 0.88"
@@ -36,9 +37,11 @@ Gem::Specification.new do |spec|
36
37
  spec.add_dependency "puma", "~> 6.0"
37
38
  spec.add_dependency "rails", "~> 8.1"
38
39
  spec.add_dependency "ratatui_ruby", "~> 1.4"
40
+ spec.add_dependency "reverse_markdown", "~> 3.0"
39
41
  spec.add_dependency "solid_cable", "~> 3.0"
40
42
  spec.add_dependency "solid_queue", "~> 1.1"
41
43
  spec.add_dependency "sqlite3", "~> 2.0"
42
44
  spec.add_dependency "toml-rb", "~> 4.0"
45
+ spec.add_dependency "toon-ruby", "~> 0.1"
43
46
  spec.add_dependency "websocket-client-simple", "~> 0.8"
44
47
  end
@@ -42,9 +42,14 @@ class SessionChannel < ApplicationCable::Channel
42
42
  ActionCable.server.broadcast(stream_name, data)
43
43
  end
44
44
 
45
- # Processes user input: persists the message and enqueues LLM processing.
46
- # When the session is actively processing an agent request, the message
47
- # is queued as "pending" and picked up after the current loop completes.
45
+ # Processes user input by emitting a {Events::UserMessage} on the event bus.
46
+ #
47
+ # When the session is idle, the emission triggers {Events::Subscribers::AgentDispatcher}
48
+ # which schedules {AgentRequestJob} to persist the event and deliver it to the LLM
49
+ # inside a transaction (Bounce Back, #236).
50
+ #
51
+ # When the session is already processing, the message is queued as "pending"
52
+ # and picked up after the current agent loop completes.
48
53
  #
49
54
  # @param data [Hash] must include "content" with the user's message text
50
55
  def speak(data)
@@ -58,7 +63,6 @@ class SessionChannel < ApplicationCable::Channel
58
63
  Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id, status: Event::PENDING_STATUS))
59
64
  else
60
65
  Events::Bus.emit(Events::UserMessage.new(content: content, session_id: @current_session_id))
61
- AgentRequestJob.perform_later(@current_session_id)
62
66
  end
63
67
  end
64
68
 
@@ -82,6 +86,21 @@ class SessionChannel < ApplicationCable::Channel
82
86
  ActionCable.server.broadcast(stream_name, {"action" => "user_message_recalled", "event_id" => event_id})
83
87
  end
84
88
 
89
+ # Requests interruption of the current tool execution. Sets a flag on the
90
+ # session that the LLM client checks between tool calls. Remaining tools
91
+ # receive synthetic "Stopped by user" results to satisfy the API's
92
+ # tool_use/tool_result pairing requirement.
93
+ #
94
+ # Atomic: a single UPDATE with WHERE avoids the read-then-write race where
95
+ # the session could finish processing between the SELECT and UPDATE.
96
+ # No-op if the session isn't currently processing.
97
+ #
98
+ # @param _data [Hash] unused
99
+ def interrupt_execution(_data)
100
+ Session.where(id: @current_session_id, processing: true)
101
+ .update_all(interrupt_requested: true)
102
+ end
103
+
85
104
  # Returns recent root sessions with nested child metadata for session picker UI.
86
105
  # Filters to root sessions only (no parent_session_id). Child sessions are
87
106
  # nested under their parent with name and status information.
@@ -126,10 +145,18 @@ class SessionChannel < ApplicationCable::Channel
126
145
  token = data["token"].to_s.strip
127
146
 
128
147
  Providers::Anthropic.validate_token_format!(token)
129
- Providers::Anthropic.validate_token_api!(token)
130
- write_anthropic_token(token)
131
148
 
132
- transmit({"action" => "token_saved"})
149
+ warning = begin
150
+ Providers::Anthropic.validate_token_api!(token)
151
+ nil
152
+ rescue Providers::Anthropic::TransientError => transient
153
+ # Token format is valid but API is temporarily unavailable (500, timeout, etc.).
154
+ # Save the token to break the prompt loop — it will work once the API recovers.
155
+ "Token saved but could not be verified — #{transient.message}"
156
+ end
157
+
158
+ write_anthropic_token(token)
159
+ transmit({"action" => "token_saved", "warning" => warning}.compact)
133
160
  rescue Providers::Anthropic::TokenFormatError, Providers::Anthropic::AuthenticationError => error
134
161
  transmit({"action" => "token_error", "message" => error.message})
135
162
  end
@@ -174,12 +201,12 @@ class SessionChannel < ApplicationCable::Channel
174
201
  # client can handle both paths with a single code path.
175
202
  #
176
203
  # Payload: session_id, name, parent_session_id, message_count,
177
- # view_mode, active_skills, goals.
204
+ # view_mode, active_skills, goals, children (when present).
178
205
  #
179
206
  # @param session [Session] the session to announce
180
207
  # @return [void]
181
208
  def transmit_session_changed(session)
182
- transmit({
209
+ payload = {
183
210
  "action" => "session_changed",
184
211
  "session_id" => session.id,
185
212
  "name" => session.name,
@@ -189,7 +216,14 @@ class SessionChannel < ApplicationCable::Channel
189
216
  "active_skills" => session.active_skills,
190
217
  "active_workflow" => session.active_workflow,
191
218
  "goals" => session.goals_summary
192
- })
219
+ }
220
+
221
+ children = session.child_sessions.order(:created_at).select(:id, :name, :processing)
222
+ if children.any?
223
+ payload["children"] = children.map { |child| {"id" => child.id, "name" => child.name, "processing" => child.processing?} }
224
+ end
225
+
226
+ transmit(payload)
193
227
  end
194
228
 
195
229
  # Switches the channel to a different session: stops current stream,
@@ -21,4 +21,10 @@ class AgentMessageDecorator < EventDecorator
21
21
  def render_debug
22
22
  render_verbose.merge(token_info)
23
23
  end
24
+
25
+ # @return [String] agent message for the analytical brain, middle-truncated
26
+ # if very long (preserves opening context and final conclusion)
27
+ def render_brain
28
+ "Assistant: #{truncate_middle(content)}"
29
+ end
24
30
  end
@@ -1,15 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Base decorator for {Event} records, providing multi-resolution rendering
4
- # for the TUI. Each event type has a dedicated subclass that implements
5
- # rendering methods for each view mode (basic, verbose, debug).
4
+ # for the TUI and analytical brain. Each event type has a dedicated subclass
5
+ # that implements rendering methods for each view mode:
6
6
  #
7
- # Decorators return structured hashes (not pre-formatted strings) so that
7
+ # - **basic** / **verbose** / **debug** TUI display modes returning structured hashes
8
+ # - **brain** — analytical brain transcript returning plain strings (or nil to skip)
9
+ #
10
+ # TUI decorators return structured hashes (not pre-formatted strings) so that
8
11
  # the TUI can style and lay out content based on semantic role, without
9
12
  # fragile regex parsing. The TUI receives structured data via ActionCable
10
13
  # and formats it for display.
11
14
  #
12
- # Subclasses must override {#render_basic}. Verbose and debug modes
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.
17
+ #
18
+ # Subclasses must override {#render_basic}. Verbose, debug, and brain modes
13
19
  # delegate to basic until subclasses provide their own implementations.
14
20
  #
15
21
  # @example Decorate an Event AR model
@@ -29,6 +35,7 @@ class EventDecorator < ApplicationDecorator
29
35
  TOOL_ICON = "\u{1F527}"
30
36
  RETURN_ARROW = "\u21A9"
31
37
  ERROR_ICON = "\u274C"
38
+ MIDDLE_TRUNCATION_MARKER = "\n[...truncated...]\n"
32
39
 
33
40
  DECORATOR_MAP = {
34
41
  "user_message" => "UserMessageDecorator",
@@ -77,14 +84,16 @@ class EventDecorator < ApplicationDecorator
77
84
  RENDER_DISPATCH = {
78
85
  "basic" => :render_basic,
79
86
  "verbose" => :render_verbose,
80
- "debug" => :render_debug
87
+ "debug" => :render_debug,
88
+ "brain" => :render_brain
81
89
  }.freeze
82
90
  private_constant :RENDER_DISPATCH
83
91
 
84
92
  # Dispatches to the render method for the given view mode.
85
93
  #
86
- # @param mode [String] one of "basic", "verbose", "debug"
87
- # @return [Hash, nil] structured event data, or nil to hide the event
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
88
97
  # @raise [ArgumentError] if the mode is not a valid view mode
89
98
  def render(mode)
90
99
  method = RENDER_DISPATCH[mode]
@@ -113,6 +122,14 @@ class EventDecorator < ApplicationDecorator
113
122
  render_basic
114
123
  end
115
124
 
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.
128
+ # @return [String, nil] formatted transcript line, or nil to skip
129
+ def render_brain
130
+ nil
131
+ end
132
+
116
133
  private
117
134
 
118
135
  # Token count for display: exact count from {CountEventTokensJob} when
@@ -155,6 +172,23 @@ class EventDecorator < ApplicationDecorator
155
172
  lines.first(max_lines).push("...").join("\n")
156
173
  end
157
174
 
175
+ # Truncates long text by cutting the middle, preserving the start and end
176
+ # so context and conclusions aren't lost. Used for brain transcripts where
177
+ # both the opening (intent) and closing (result) matter.
178
+ #
179
+ # @param text [String, nil] text to truncate
180
+ # @param max_chars [Integer] maximum character length before truncation
181
+ # @return [String] original text or start + marker + end
182
+ def truncate_middle(text, max_chars: 500)
183
+ str = text.to_s
184
+ return str if str.length <= max_chars
185
+
186
+ keep = max_chars - MIDDLE_TRUNCATION_MARKER.length
187
+ head = keep / 2
188
+ tail = keep - head
189
+ "#{str[0, head]}#{MIDDLE_TRUNCATION_MARKER}#{str[-tail, tail]}"
190
+ end
191
+
158
192
  # Normalizes input to something Draper can wrap.
159
193
  # Event AR models pass through; hashes become EventPayload structs
160
194
  # with string-normalized keys.
@@ -1,36 +1,95 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "toon"
4
+
3
5
  # Decorates tool_call events for display in the TUI.
4
6
  # Hidden in basic mode — tool activity is represented by the
5
7
  # aggregated tool counter instead. Verbose mode returns tool name
6
8
  # and a formatted preview of the input arguments. Debug mode shows
7
- # full untruncated input as pretty-printed JSON with tool_use_id.
9
+ # full untruncated input in TOON format with tool_use_id.
10
+ #
11
+ # Think tool calls are special: "aloud" thoughts are shown in all
12
+ # view modes (with a thought bubble), while "inner" thoughts are
13
+ # visible only in verbose and debug modes.
8
14
  class ToolCallDecorator < EventDecorator
9
- # @return [nil] tool calls are hidden in basic mode
15
+ THINK_TOOL = "think"
16
+
17
+ # In basic mode, only "aloud" think calls are visible.
18
+ # All other tool calls are hidden (represented by the tool counter).
19
+ #
20
+ # @return [Hash, nil] structured think data for aloud thoughts, nil otherwise
10
21
  def render_basic
11
- nil
22
+ return unless think?
23
+ return unless aloud?
24
+
25
+ {role: :think, content: thoughts, visibility: "aloud"}
12
26
  end
13
27
 
14
28
  # @return [Hash] structured tool call data
15
29
  # `{role: :tool_call, tool: String, input: String, timestamp: Integer|nil}`
16
30
  def render_verbose
31
+ return render_think_verbose if think?
32
+
17
33
  {role: :tool_call, tool: payload["tool_name"], input: format_input, timestamp: timestamp}
18
34
  end
19
35
 
20
36
  # @return [Hash] full tool call data with untruncated input and tool_use_id
21
37
  # `{role: :tool_call, tool: String, input: String, tool_use_id: String|nil, timestamp: Integer|nil}`
22
38
  def render_debug
39
+ return render_think_debug if think?
40
+
23
41
  {
24
42
  role: :tool_call,
25
43
  tool: payload["tool_name"],
26
- input: JSON.pretty_generate(payload["tool_input"] || {}),
44
+ input: Toon.encode(payload["tool_input"] || {}),
27
45
  tool_use_id: payload["tool_use_id"],
28
46
  timestamp: timestamp
29
47
  }
30
48
  end
31
49
 
50
+ # Think calls get full text — the agent's reasoning IS the signal.
51
+ # Other tool calls show tool name + params (compact JSON).
52
+ # @return [String] transcript line for the analytical brain
53
+ def render_brain
54
+ if think?
55
+ "Think: #{thoughts}"
56
+ else
57
+ "Tool call: #{payload["tool_name"]}(#{tool_input.to_json})"
58
+ end
59
+ end
60
+
32
61
  private
33
62
 
63
+ def think?
64
+ payload["tool_name"] == THINK_TOOL
65
+ end
66
+
67
+ def aloud?
68
+ tool_input.dig("visibility") == "aloud"
69
+ end
70
+
71
+ def thoughts
72
+ tool_input.dig("thoughts").to_s
73
+ end
74
+
75
+ def tool_input
76
+ payload["tool_input"] || {}
77
+ end
78
+
79
+ def visibility
80
+ tool_input.dig("visibility") || "inner"
81
+ end
82
+
83
+ # @return [Hash] think event for verbose mode — both inner and aloud visible
84
+ def render_think_verbose
85
+ {role: :think, content: thoughts, visibility: visibility, timestamp: timestamp}
86
+ end
87
+
88
+ # @return [Hash] think event for debug mode — full metadata
89
+ def render_think_debug
90
+ {role: :think, content: thoughts, visibility: visibility, tool_use_id: payload["tool_use_id"], timestamp: timestamp}
91
+ end
92
+
34
93
  # Formats tool input for display, with tool-specific formatting for
35
94
  # known tools and generic JSON fallback for others.
36
95
  # @return [String] formatted input preview
@@ -41,8 +100,10 @@ class ToolCallDecorator < EventDecorator
41
100
  "$ #{input&.dig("command")}"
42
101
  when "web_get"
43
102
  "GET #{input&.dig("url")}"
103
+ when "read", "edit", "write"
104
+ input&.dig("file_path").to_s
44
105
  else
45
- truncate_lines(input.to_json, max_lines: 2)
106
+ truncate_lines(Toon.encode(input), max_lines: 2)
46
107
  end
47
108
  end
48
109
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base class for server-side tool response decoration. Transforms raw tool
4
+ # results into LLM-optimized formats before they enter the event stream.
5
+ #
6
+ # This is a separate decorator type from {EventDecorator}: EventDecorator
7
+ # formats events for clients (TUI/web), while ToolDecorator formats tool
8
+ # responses for the LLM. They sit at different points in the pipeline:
9
+ #
10
+ # Tool executes → ToolDecorator transforms → event stream → EventDecorator renders
11
+ #
12
+ # Subclasses implement {#call} to transform a tool's raw result into an
13
+ # LLM-friendly string. Each tool can have its own ToolDecorator subclass
14
+ # (e.g. {WebGetToolDecorator}) registered in {DECORATOR_MAP}.
15
+ #
16
+ # @example Decorating a tool result
17
+ # ToolDecorator.call("web_get", {body: html, content_type: "text/html"})
18
+ # #=> "[Converted: HTML → Markdown]\n\n# Page Title\n..."
19
+ class ToolDecorator
20
+ DECORATOR_MAP = {
21
+ "web_get" => "WebGetToolDecorator"
22
+ }.freeze
23
+
24
+ # Factory: dispatches to the tool-specific decorator or passes through.
25
+ #
26
+ # @param tool_name [String] registered tool name
27
+ # @param result [String, Hash] raw tool execution result
28
+ # @return [String, Hash] decorated result (String) or original error Hash
29
+ def self.call(tool_name, result)
30
+ return result if result.is_a?(Hash) && result.key?(:error)
31
+
32
+ klass_name = DECORATOR_MAP[tool_name]
33
+ return result unless klass_name
34
+
35
+ klass_name.constantize.new.call(result)
36
+ end
37
+
38
+ # Subclasses override to transform the raw tool result.
39
+ #
40
+ # @param result [String, Hash] raw tool execution result
41
+ # @return [String] LLM-optimized content
42
+ def call(result)
43
+ raise NotImplementedError, "#{self.class} must implement #call"
44
+ end
45
+
46
+ private
47
+
48
+ # Combines decorated text with an optional metadata tag so the LLM
49
+ # knows the content was transformed.
50
+ #
51
+ # @param text [String] the transformed content
52
+ # @param meta [String, nil] conversion tag (e.g. "[Converted: HTML → Markdown]")
53
+ # @return [String]
54
+ def assemble(text:, meta:)
55
+ meta ? "#{meta}\n\n#{text}" : text
56
+ end
57
+ end
@@ -3,19 +3,29 @@
3
3
  # Decorates tool_response events 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
- # output with a success/failure indicator. Debug mode shows full
7
- # untruncated output with tool_use_id and estimated token count.
6
+ # output with a success/failure indicator and tool name for per-tool
7
+ # client-side rendering. Debug mode shows full untruncated output
8
+ # with tool_use_id and estimated token count.
9
+ #
10
+ # Think tool responses ("OK") are hidden in basic and verbose modes
11
+ # because the value is in the tool_call (the thoughts), not the response.
8
12
  class ToolResponseDecorator < EventDecorator
13
+ THINK_TOOL = "think"
14
+
9
15
  # @return [nil] tool responses are hidden in basic mode
10
16
  def render_basic
11
17
  nil
12
18
  end
13
19
 
14
- # @return [Hash] structured tool response data
15
- # `{role: :tool_response, content: String, success: Boolean, timestamp: Integer|nil}`
20
+ # Think responses are hidden in verbose mode — the "OK" adds no information.
21
+ # @return [Hash, nil] structured tool response data, nil for think responses
22
+ # `{role: :tool_response, tool: String, content: String, success: Boolean, timestamp: Integer|nil}`
16
23
  def render_verbose
24
+ return if think?
25
+
17
26
  {
18
27
  role: :tool_response,
28
+ tool: tool_name,
19
29
  content: truncate_lines(content, max_lines: 3),
20
30
  success: payload["success"] != false,
21
31
  timestamp: timestamp
@@ -23,15 +33,35 @@ class ToolResponseDecorator < EventDecorator
23
33
  end
24
34
 
25
35
  # @return [Hash] full tool response data with untruncated content, tool_use_id, and token estimate
26
- # `{role: :tool_response, content: String, success: Boolean, tool_use_id: String|nil,
36
+ # `{role: :tool_response, tool: String, content: String, success: Boolean, tool_use_id: String|nil,
27
37
  # timestamp: Integer|nil, tokens: Integer, estimated: Boolean}`
28
38
  def render_debug
29
39
  {
30
40
  role: :tool_response,
41
+ tool: tool_name,
31
42
  content: content,
32
43
  success: payload["success"] != false,
33
44
  tool_use_id: payload["tool_use_id"],
34
45
  timestamp: timestamp
35
46
  }.merge(token_info)
36
47
  end
48
+
49
+ # Think responses ("OK") are noise — excluded from the brain's transcript.
50
+ # Other tool responses are compressed to success/failure indicators only.
51
+ # @return [String, nil] ✅ or ❌ indicator, nil for think responses
52
+ def render_brain
53
+ return if think?
54
+
55
+ (payload["success"] != false) ? "\u2705" : "\u274C"
56
+ end
57
+
58
+ private
59
+
60
+ def tool_name
61
+ payload["tool_name"]
62
+ end
63
+
64
+ def think?
65
+ tool_name == THINK_TOOL
66
+ end
37
67
  end
@@ -26,6 +26,12 @@ class UserMessageDecorator < EventDecorator
26
26
  render_verbose.merge(token_info)
27
27
  end
28
28
 
29
+ # @return [String] user message for the analytical brain, middle-truncated
30
+ # if very long (preserves intent at start and conclusion at end)
31
+ def render_brain
32
+ "User: #{truncate_middle(content)}"
33
+ end
34
+
29
35
  private
30
36
 
31
37
  # @return [Boolean] true when this message is queued but not yet sent to LLM
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "reverse_markdown"
4
+ require "toon"
5
+
6
+ # Transforms {Tools::WebGet} responses for LLM consumption by detecting
7
+ # the Content-Type header and applying format-specific conversion.
8
+ #
9
+ # Content-Type maps to a method name via simple string normalization:
10
+ # "application/json" → {#application_json}
11
+ # "text/html" → {#text_html}
12
+ # "text/plain" → method_missing → passthrough
13
+ #
14
+ # Adding a new format = adding one method. Unknown types fall through
15
+ # {#method_missing} and pass through unchanged.
16
+ #
17
+ # @example
18
+ # decorator = WebGetToolDecorator.new
19
+ # decorator.call(body: "<h1>Hi</h1>", content_type: "text/html")
20
+ # #=> "[Converted: HTML → Markdown]\n\n# Hi"
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
25
+
26
+ # @param result [Hash] `{body: String, content_type: String}`
27
+ # @return [String] LLM-optimized content with conversion metadata tag
28
+ def call(result)
29
+ return result.to_s unless result.is_a?(Hash) && result.key?(:body)
30
+
31
+ body = result[:body].to_s
32
+ content_type = result[:content_type] || "text/plain"
33
+ decorated = decorate(body, content_type: content_type)
34
+
35
+ assemble(**decorated)
36
+ end
37
+
38
+ # Dispatches to the format-specific method derived from Content-Type.
39
+ #
40
+ # @param body [String] raw response body
41
+ # @param content_type [String] HTTP Content-Type header value
42
+ # @return [Hash] `{text: String, meta: String|nil}`
43
+ def decorate(body, content_type:)
44
+ method_name = content_type.split(";").first.strip.tr("/", "_").tr("-", "_")
45
+ public_send(method_name, body)
46
+ end
47
+
48
+ # Compresses JSON using TOON (Token-Optimized Object Notation) for
49
+ # ~40% token savings on typical JSON arrays.
50
+ #
51
+ # @param body [String] JSON response body
52
+ # @return [Hash] `{text: String, meta: String}`
53
+ def application_json(body)
54
+ parsed = JSON.parse(body)
55
+ {text: Toon.encode(parsed), meta: "[Converted: JSON → TOON]"}
56
+ rescue JSON::ParserError
57
+ {text: body, meta: nil}
58
+ end
59
+
60
+ # Strips noise elements (scripts, styles, nav, ads) and converts
61
+ # semantic HTML to Markdown for clean LLM consumption.
62
+ #
63
+ # @param body [String] HTML response body
64
+ # @return [Hash] `{text: String, meta: String}`
65
+ def text_html(body)
66
+ markdown = html_to_markdown(body)
67
+ {text: markdown, meta: "[Converted: HTML → Markdown]"}
68
+ end
69
+
70
+ # Passthrough for unregistered content types.
71
+ #
72
+ # @return [Hash] `{text: String, meta: nil}`
73
+ def method_missing(_method_name, body, *)
74
+ {text: body, meta: nil}
75
+ end
76
+
77
+ def respond_to_missing?(*, **)
78
+ true
79
+ end
80
+
81
+ private
82
+
83
+ # Strips noise HTML elements then converts to Markdown.
84
+ #
85
+ # @param html [String] raw HTML
86
+ # @return [String] clean Markdown
87
+ def html_to_markdown(html)
88
+ doc = Nokogiri::HTML(html)
89
+ doc.css(NOISE_TAGS.join(", ")).remove
90
+ clean_html = doc.at("body")&.inner_html || doc.to_html
91
+ markdown = ReverseMarkdown.convert(clean_html, unknown_tags: :bypass, github_flavored: true)
92
+ collapse_whitespace(markdown)
93
+ end
94
+
95
+ # Collapses excessive blank lines down to a single blank line.
96
+ #
97
+ # @param text [String]
98
+ # @return [String]
99
+ def collapse_whitespace(text)
100
+ text.gsub(/\n{3,}/, "\n\n").strip
101
+ end
102
+ end