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.
- checksums.yaml +4 -4
- data/.gitattributes +1 -0
- data/.reek.yml +61 -0
- data/README.md +202 -116
- data/anima-core.gemspec +4 -1
- data/app/channels/session_channel.rb +44 -10
- data/app/decorators/agent_message_decorator.rb +6 -0
- data/app/decorators/event_decorator.rb +41 -7
- data/app/decorators/tool_call_decorator.rb +66 -5
- data/app/decorators/tool_decorator.rb +57 -0
- data/app/decorators/tool_response_decorator.rb +35 -5
- data/app/decorators/user_message_decorator.rb +6 -0
- data/app/decorators/web_get_tool_decorator.rb +102 -0
- data/app/jobs/agent_request_job.rb +95 -20
- data/app/jobs/mneme_job.rb +51 -0
- data/app/jobs/passive_recall_job.rb +29 -0
- data/app/models/concerns/event/broadcasting.rb +18 -0
- data/app/models/event.rb +10 -0
- data/app/models/goal.rb +27 -0
- data/app/models/goal_pinned_event.rb +11 -0
- data/app/models/pinned_event.rb +41 -0
- data/app/models/session.rb +335 -6
- data/app/models/snapshot.rb +76 -0
- data/config/initializers/event_subscribers.rb +14 -3
- data/config/initializers/fts5_schema_dump.rb +21 -0
- data/db/migrate/20260316094817_add_interrupt_requested_to_sessions.rb +5 -0
- data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
- data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
- data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
- data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
- data/lib/agent_loop.rb +67 -18
- data/lib/analytical_brain/runner.rb +159 -84
- data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
- data/lib/analytical_brain/tools/finish_goal.rb +6 -1
- data/lib/anima/cli.rb +34 -1
- data/lib/anima/config_migrator.rb +205 -0
- data/lib/anima/installer.rb +13 -130
- data/lib/anima/settings.rb +42 -1
- data/lib/anima/version.rb +1 -1
- data/lib/events/bounce_back.rb +37 -0
- data/lib/events/subscribers/agent_dispatcher.rb +29 -0
- data/lib/events/subscribers/persister.rb +17 -0
- data/lib/events/subscribers/subagent_message_router.rb +102 -0
- data/lib/events/subscribers/transient_broadcaster.rb +36 -0
- data/lib/llm/client.rb +99 -14
- data/lib/mneme/compressed_viewport.rb +200 -0
- data/lib/mneme/l2_runner.rb +138 -0
- data/lib/mneme/passive_recall.rb +69 -0
- data/lib/mneme/runner.rb +254 -0
- data/lib/mneme/search.rb +150 -0
- data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
- data/lib/mneme/tools/everything_ok.rb +24 -0
- data/lib/mneme/tools/save_snapshot.rb +68 -0
- data/lib/mneme.rb +29 -0
- data/lib/providers/anthropic.rb +57 -13
- data/lib/shell_session.rb +188 -59
- data/lib/tasks/fts5.rake +6 -0
- data/lib/tools/remember.rb +179 -0
- data/lib/tools/spawn_specialist.rb +21 -9
- data/lib/tools/spawn_subagent.rb +22 -11
- data/lib/tools/subagent_prompts.rb +20 -3
- data/lib/tools/think.rb +57 -0
- data/lib/tools/web_get.rb +15 -6
- data/lib/tui/app.rb +230 -127
- data/lib/tui/cable_client.rb +8 -0
- data/lib/tui/decorators/base_decorator.rb +165 -0
- data/lib/tui/decorators/bash_decorator.rb +20 -0
- data/lib/tui/decorators/edit_decorator.rb +19 -0
- data/lib/tui/decorators/read_decorator.rb +24 -0
- data/lib/tui/decorators/think_decorator.rb +36 -0
- data/lib/tui/decorators/web_get_decorator.rb +19 -0
- data/lib/tui/decorators/write_decorator.rb +19 -0
- data/lib/tui/flash.rb +139 -0
- data/lib/tui/formatting.rb +28 -0
- data/lib/tui/height_map.rb +93 -0
- data/lib/tui/message_store.rb +25 -1
- data/lib/tui/performance_logger.rb +90 -0
- data/lib/tui/screens/chat.rb +374 -109
- data/templates/config.toml +156 -0
- metadata +87 -4
- data/CHANGELOG.md +0 -79
- data/Gemfile +0 -17
- 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
|
|
46
|
-
#
|
|
47
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
# rendering methods for each view mode
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
7
|
-
#
|
|
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
|
-
#
|
|
15
|
-
#
|
|
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
|