anima-core 1.1.3 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +2 -0
- data/agents/codebase-analyzer.md +1 -1
- data/agents/codebase-pattern-finder.md +1 -1
- data/agents/documentation-researcher.md +1 -1
- data/agents/thoughts-analyzer.md +1 -1
- data/agents/web-search-researcher.md +1 -1
- data/app/channels/session_channel.rb +44 -43
- data/app/decorators/agent_message_decorator.rb +2 -2
- data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
- data/app/decorators/system_message_decorator.rb +2 -2
- data/app/decorators/tool_call_decorator.rb +2 -2
- data/app/decorators/tool_decorator.rb +4 -4
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +3 -3
- data/app/decorators/web_get_tool_decorator.rb +41 -9
- data/app/jobs/agent_request_job.rb +20 -20
- data/app/jobs/count_message_tokens_job.rb +39 -0
- data/app/jobs/passive_recall_job.rb +4 -4
- data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
- data/app/models/goal.rb +4 -4
- data/app/models/goal_pinned_message.rb +11 -0
- data/app/models/{event.rb → message.rb} +42 -39
- data/app/models/pinned_message.rb +41 -0
- data/app/models/session.rb +206 -198
- data/app/models/snapshot.rb +25 -25
- data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
- data/lib/agent_loop.rb +6 -6
- data/lib/analytical_brain/runner.rb +35 -35
- data/lib/analytical_brain/tools/activate_skill.rb +5 -9
- data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
- data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
- data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
- data/lib/analytical_brain/tools/finish_goal.rb +5 -8
- data/lib/analytical_brain/tools/read_workflow.rb +5 -9
- data/lib/analytical_brain/tools/rename_session.rb +3 -10
- data/lib/analytical_brain/tools/set_goal.rb +3 -7
- data/lib/analytical_brain/tools/update_goal.rb +3 -7
- data/lib/anima/settings.rb +15 -4
- data/lib/anima/version.rb +1 -1
- data/lib/events/bounce_back.rb +7 -7
- data/lib/events/subscribers/persister.rb +7 -7
- data/lib/events/subscribers/transient_broadcaster.rb +2 -2
- data/lib/mneme/compressed_viewport.rb +57 -57
- data/lib/mneme/l2_runner.rb +4 -4
- data/lib/mneme/passive_recall.rb +2 -2
- data/lib/mneme/runner.rb +57 -75
- data/lib/mneme/search.rb +38 -38
- data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
- data/lib/mneme/tools/everything_ok.rb +1 -3
- data/lib/mneme/tools/save_snapshot.rb +12 -16
- data/lib/tools/bash.rb +4 -12
- data/lib/tools/edit.rb +4 -6
- data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
- data/lib/tools/read.rb +4 -4
- data/lib/tools/registry.rb +1 -1
- data/lib/tools/remember.rb +46 -55
- data/lib/tools/spawn_specialist.rb +12 -23
- data/lib/tools/spawn_subagent.rb +9 -19
- data/lib/tools/subagent_prompts.rb +0 -2
- data/lib/tools/think.rb +3 -10
- data/lib/tools/web_get.rb +23 -4
- data/lib/tools/write.rb +3 -3
- data/lib/tui/cable_client.rb +3 -3
- data/lib/tui/message_store.rb +37 -37
- data/lib/tui/screens/chat.rb +27 -15
- data/skills/activerecord/SKILL.md +1 -1
- data/skills/dragonruby/SKILL.md +1 -1
- data/skills/draper-decorators/SKILL.md +1 -1
- data/skills/gh-issue.md +1 -1
- data/skills/mcp-server/SKILL.md +1 -1
- data/skills/ratatui-ruby/SKILL.md +1 -1
- data/skills/rspec/SKILL.md +1 -1
- data/templates/config.toml +16 -5
- data/templates/soul.md +7 -19
- data/workflows/create_handoff.md +1 -1
- data/workflows/create_note.md +1 -1
- data/workflows/create_plan.md +1 -1
- data/workflows/implement_plan.md +1 -1
- data/workflows/iterate_plan.md +1 -1
- data/workflows/research_codebase.md +1 -1
- data/workflows/resume_handoff.md +1 -1
- data/workflows/review_pr.md +78 -16
- data/workflows/thoughts_init.md +1 -1
- data/workflows/validate_plan.md +1 -1
- metadata +10 -9
- data/app/jobs/count_event_tokens_job.rb +0 -39
- data/app/models/goal_pinned_event.rb +0 -11
- data/app/models/pinned_event.rb +0 -41
- data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f8a12f6624492adc6c4de4e5fbc5e28df74edf73a568b73fc0819a8d3fc45163
|
|
4
|
+
data.tar.gz: 84f3c8680deb81a5e4989abd7b9eeafaae9bdc8e5b0a04db6af27c615f31144c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae5c8c7ba47910544c6bb3b316bed1a90a54929cae275983c8dd36fca91cdceebbd06cd50b6a1e512d1142e38ab8ebc414b635dd0ed0ff67eea74c5e0af86d86
|
|
7
|
+
data.tar.gz: 8d8c6ddf84970520c805da2b2fb98d6b2c47faad068f5e602e89389bcc7e70bd206a0ed35dc7dc94aeef2bb8cea615e57627fcf883937d23ad2f70f5610bd6e2
|
data/.reek.yml
CHANGED
|
@@ -37,6 +37,8 @@ detectors:
|
|
|
37
37
|
- "Tools::SpawnSpecialist#execute"
|
|
38
38
|
# Nickname assignment operates on child session and parent's children — inherent.
|
|
39
39
|
- "Tools::SubagentPrompts#assign_nickname_via_brain"
|
|
40
|
+
# Goal tools operate on goal objects — inherent to the pattern.
|
|
41
|
+
- "AnalyticalBrain::Tools::UpdateGoal#execute"
|
|
40
42
|
# Validation methods naturally reference the validated value more than self.
|
|
41
43
|
- "AnalyticalBrain::Tools::AssignNickname#validate"
|
|
42
44
|
# Tool execute methods naturally reference input hash and shell result hash.
|
data/agents/codebase-analyzer.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: codebase-analyzer
|
|
3
|
-
description:
|
|
3
|
+
description: Traces data flow and explains how code works. Returns file:line references.
|
|
4
4
|
tools: read, bash
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: codebase-pattern-finder
|
|
3
|
-
description: Finds similar implementations, usage examples, and existing patterns
|
|
3
|
+
description: Finds similar implementations, usage examples, and existing patterns to model after. Returns concrete code examples.
|
|
4
4
|
tools: read, bash
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: documentation-researcher
|
|
3
|
-
description: Fetches
|
|
3
|
+
description: Fetches official docs. Returns ready-to-use code examples.
|
|
4
4
|
tools: web_get, read
|
|
5
5
|
color: cyan
|
|
6
6
|
---
|
data/agents/thoughts-analyzer.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: thoughts-analyzer
|
|
3
|
-
description:
|
|
3
|
+
description: "thoughts/ holds design decisions, architecture notes, and implementation rationale. Answers WHY things work the way they do and how they should work by design."
|
|
4
4
|
tools: read, bash
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: web-search-researcher
|
|
3
|
-
description:
|
|
3
|
+
description: Researches topics across multiple web sources. Use when a single page won't answer the question.
|
|
4
4
|
tools: web_get, bash, read
|
|
5
5
|
color: yellow
|
|
6
6
|
---
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Streams
|
|
4
|
-
# Part of the Brain/TUI separation: the Brain broadcasts
|
|
3
|
+
# Streams messages for a specific session to connected clients.
|
|
4
|
+
# Part of the Brain/TUI separation: the Brain broadcasts messages through
|
|
5
5
|
# this channel, and any number of clients (TUI, web, API) can subscribe.
|
|
6
6
|
#
|
|
7
7
|
# On subscription, sends the session's chat history so the client can
|
|
@@ -42,10 +42,10 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
42
42
|
ActionCable.server.broadcast(stream_name, data)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
# Processes user input. For idle sessions, persists the
|
|
46
|
-
# so
|
|
47
|
-
#
|
|
48
|
-
#
|
|
45
|
+
# Processes user input. For idle sessions, persists the message immediately
|
|
46
|
+
# so it appears in the TUI without waiting for the background job, then
|
|
47
|
+
# schedules {AgentRequestJob} for LLM delivery. If delivery fails, the
|
|
48
|
+
# job deletes the message and emits a {Events::BounceBack}.
|
|
49
49
|
#
|
|
50
50
|
# For busy sessions, emits a pending {Events::UserMessage} that queues
|
|
51
51
|
# until the current agent loop completes.
|
|
@@ -63,23 +63,23 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
# Recalls the most recent pending message for editing. Deletes the
|
|
66
|
-
# pending
|
|
66
|
+
# pending message and broadcasts the recall so all clients remove it.
|
|
67
67
|
#
|
|
68
|
-
# @param data [Hash] must include "
|
|
68
|
+
# @param data [Hash] must include "message_id" (positive integer)
|
|
69
69
|
def recall_pending(data)
|
|
70
|
-
|
|
71
|
-
return if
|
|
70
|
+
message_id = data["message_id"].to_i
|
|
71
|
+
return if message_id <= 0
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
id:
|
|
73
|
+
message = Message.find_by(
|
|
74
|
+
id: message_id,
|
|
75
75
|
session_id: @current_session_id,
|
|
76
|
-
|
|
77
|
-
status:
|
|
76
|
+
message_type: "user_message",
|
|
77
|
+
status: Message::PENDING_STATUS
|
|
78
78
|
)
|
|
79
|
-
return unless
|
|
79
|
+
return unless message
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
ActionCable.server.broadcast(stream_name, {"action" => "user_message_recalled", "
|
|
81
|
+
message.destroy!
|
|
82
|
+
ActionCable.server.broadcast(stream_name, {"action" => "user_message_recalled", "message_id" => message_id})
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
# Requests interruption of the current tool execution. Sets a flag on the
|
|
@@ -106,7 +106,7 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
106
106
|
limit = (data["limit"] || DEFAULT_LIST_LIMIT).to_i.clamp(1, MAX_LIST_LIMIT)
|
|
107
107
|
sessions = Session.root_sessions.recent(limit).includes(:child_sessions)
|
|
108
108
|
all_ids = sessions.flat_map { |session| [session.id] + session.child_sessions.map(&:id) }
|
|
109
|
-
counts =
|
|
109
|
+
counts = Message.where(session_id: all_ids).llm_messages.group(:session_id).count
|
|
110
110
|
|
|
111
111
|
result = sessions.map { |session| serialize_session_with_children(session, counts) }
|
|
112
112
|
transmit({"action" => "sessions_list", "sessions" => result})
|
|
@@ -196,7 +196,7 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
196
196
|
# Used on initial subscription and after session switches so the
|
|
197
197
|
# client can handle both paths with a single code path.
|
|
198
198
|
#
|
|
199
|
-
# Payload: session_id, name, parent_session_id, message_count,
|
|
199
|
+
# Payload: session_id, name, agent_name, parent_session_id, message_count,
|
|
200
200
|
# view_mode, active_skills, goals, children (when present).
|
|
201
201
|
#
|
|
202
202
|
# @param session [Session] the session to announce
|
|
@@ -206,8 +206,9 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
206
206
|
"action" => "session_changed",
|
|
207
207
|
"session_id" => session.id,
|
|
208
208
|
"name" => session.name,
|
|
209
|
+
"agent_name" => Anima::Settings.agent_name,
|
|
209
210
|
"parent_session_id" => session.parent_session_id,
|
|
210
|
-
"message_count" => session.
|
|
211
|
+
"message_count" => session.messages.llm_messages.count,
|
|
211
212
|
"view_mode" => session.view_mode,
|
|
212
213
|
"active_skills" => session.active_skills,
|
|
213
214
|
"active_workflow" => session.active_workflow,
|
|
@@ -244,22 +245,22 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
244
245
|
transmit({"action" => "view_mode", "view_mode" => session.view_mode})
|
|
245
246
|
end
|
|
246
247
|
|
|
247
|
-
# Sends decorated context
|
|
248
|
-
# the LLM's viewport to the subscribing client. Each
|
|
249
|
-
# in
|
|
250
|
-
# the transmitted payload. Tool
|
|
248
|
+
# Sends decorated context messages (conversation + tool interactions) from
|
|
249
|
+
# the LLM's viewport to the subscribing client. Each message is wrapped
|
|
250
|
+
# in a {MessageDecorator} and the pre-rendered output is included in
|
|
251
|
+
# the transmitted payload. Tool messages are included so the TUI can
|
|
251
252
|
# reconstruct tool call counters on reconnect.
|
|
252
253
|
# In debug mode, prepends the assembled system prompt as a special block.
|
|
253
254
|
#
|
|
254
|
-
# Snapshots the viewport so subsequent
|
|
255
|
+
# Snapshots the viewport so subsequent message broadcasts can compute
|
|
255
256
|
# eviction diffs accurately.
|
|
256
257
|
#
|
|
257
258
|
# @param session [Session] the session whose history to transmit
|
|
258
259
|
def transmit_history(session)
|
|
259
260
|
transmit_system_prompt(session) if session.view_mode == "debug"
|
|
260
261
|
|
|
261
|
-
|
|
262
|
-
transmit(
|
|
262
|
+
each_viewport_message(session) do |_msg, msg_payload|
|
|
263
|
+
transmit(msg_payload)
|
|
263
264
|
end
|
|
264
265
|
end
|
|
265
266
|
|
|
@@ -267,7 +268,7 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
267
268
|
# Used after a view mode change to refresh all connected clients.
|
|
268
269
|
# In debug mode, prepends the assembled system prompt as a special block.
|
|
269
270
|
#
|
|
270
|
-
# Snapshots the viewport so subsequent
|
|
271
|
+
# Snapshots the viewport so subsequent message broadcasts can compute
|
|
271
272
|
# eviction diffs accurately.
|
|
272
273
|
#
|
|
273
274
|
# @param session [Session] the session whose viewport to broadcast
|
|
@@ -275,40 +276,40 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
275
276
|
def broadcast_viewport(session)
|
|
276
277
|
broadcast_system_prompt(session) if session.view_mode == "debug"
|
|
277
278
|
|
|
278
|
-
|
|
279
|
-
ActionCable.server.broadcast(stream_name,
|
|
279
|
+
each_viewport_message(session) do |_msg, msg_payload|
|
|
280
|
+
ActionCable.server.broadcast(stream_name, msg_payload)
|
|
280
281
|
end
|
|
281
282
|
end
|
|
282
283
|
|
|
283
284
|
# Loads the viewport, snapshots it for eviction tracking, and yields
|
|
284
|
-
# each
|
|
285
|
+
# each message with its decorated payload. Snapshot uses snapshot_viewport!
|
|
285
286
|
# (not recalculate_viewport!) because full viewport refreshes don't need
|
|
286
287
|
# eviction diffs — clients clear their store before rendering.
|
|
287
288
|
#
|
|
288
289
|
# @param session [Session] the session whose viewport to iterate
|
|
289
|
-
# @yieldparam
|
|
290
|
+
# @yieldparam message [Message] the persisted message record
|
|
290
291
|
# @yieldparam payload [Hash] decorated payload ready for transmission
|
|
291
292
|
# @return [void]
|
|
292
|
-
def
|
|
293
|
-
viewport = session.
|
|
293
|
+
def each_viewport_message(session)
|
|
294
|
+
viewport = session.viewport_messages
|
|
294
295
|
session.snapshot_viewport!(viewport.map(&:id))
|
|
295
296
|
|
|
296
|
-
viewport.each do |
|
|
297
|
-
yield
|
|
297
|
+
viewport.each do |msg|
|
|
298
|
+
yield msg, decorate_message_payload(msg, session.view_mode)
|
|
298
299
|
end
|
|
299
300
|
end
|
|
300
301
|
|
|
301
|
-
# Decorates
|
|
302
|
+
# Decorates a message for transmission to clients. Merges the message's
|
|
302
303
|
# database ID and structured decorator output into the payload.
|
|
303
304
|
# Used by {#transmit_history} and {#broadcast_viewport} for historical
|
|
304
|
-
# and viewport re-broadcast — live broadcasts use {
|
|
305
|
+
# and viewport re-broadcast — live broadcasts use {Message::Broadcasting}.
|
|
305
306
|
#
|
|
306
|
-
# @param
|
|
307
|
+
# @param message [Message] persisted message record
|
|
307
308
|
# @param mode [String] view mode for decoration (default: "basic")
|
|
308
309
|
# @return [Hash] payload with "id" and optional "rendered" key
|
|
309
|
-
def
|
|
310
|
-
payload =
|
|
311
|
-
decorator =
|
|
310
|
+
def decorate_message_payload(message, mode = "basic")
|
|
311
|
+
payload = message.payload.merge("id" => message.id)
|
|
312
|
+
decorator = MessageDecorator.for(message)
|
|
312
313
|
return payload unless decorator
|
|
313
314
|
|
|
314
315
|
payload.merge("rendered" => {mode => decorator.render(mode)})
|
|
@@ -343,7 +344,7 @@ class SessionChannel < ApplicationCable::Channel
|
|
|
343
344
|
prompt = session.system_prompt
|
|
344
345
|
return unless prompt
|
|
345
346
|
|
|
346
|
-
tokens = [(prompt.bytesize /
|
|
347
|
+
tokens = [(prompt.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
|
|
347
348
|
{
|
|
348
349
|
"type" => "system_prompt",
|
|
349
350
|
"rendered" => {
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Decorates agent_message
|
|
3
|
+
# Decorates agent_message records for display in the TUI.
|
|
4
4
|
# Basic mode returns role and content. Verbose mode adds a timestamp.
|
|
5
5
|
# Debug mode adds token count (exact when counted, estimated when not).
|
|
6
|
-
class AgentMessageDecorator <
|
|
6
|
+
class AgentMessageDecorator < MessageDecorator
|
|
7
7
|
# @return [Hash] structured agent message data
|
|
8
8
|
# `{role: :assistant, content: String}`
|
|
9
9
|
def render_basic
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Base decorator for {
|
|
4
|
-
# for the TUI and analytical brain. Each
|
|
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
|
-
#
|
|
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
|
|
22
|
-
# decorator =
|
|
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 =
|
|
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 =
|
|
30
|
+
# decorator = MessageDecorator.for(type: "user_message", content: "hello")
|
|
31
31
|
# decorator.render_basic #=> {role: :user, content: "hello"}
|
|
32
|
-
class
|
|
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
|
|
50
|
-
# can use {#payload}, {#
|
|
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
|
|
54
|
-
# @!attribute payload [r] string-keyed hash of
|
|
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
|
-
|
|
58
|
-
# Heuristic token estimate matching {
|
|
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
|
|
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 /
|
|
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
|
|
72
|
-
# Hashes are normalized via {
|
|
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
|
|
75
|
-
# @return [
|
|
76
|
-
def self.for(
|
|
77
|
-
source = wrap_source(
|
|
78
|
-
klass_name = DECORATOR_MAP[source.
|
|
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
|
|
96
|
-
# plain string (brain), or nil to hide the
|
|
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
|
|
106
|
-
# @return [Hash, nil] structured
|
|
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
|
|
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
|
|
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
|
-
#
|
|
127
|
-
# Subclasses override to provide
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
#
|
|
193
|
+
# Message AR models pass through; hashes become MessagePayload structs
|
|
194
194
|
# with string-normalized keys.
|
|
195
|
-
def self.wrap_source(
|
|
196
|
-
return
|
|
195
|
+
def self.wrap_source(message)
|
|
196
|
+
return message unless message.is_a?(Hash)
|
|
197
197
|
|
|
198
|
-
normalized =
|
|
199
|
-
|
|
200
|
-
|
|
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
|
|
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 <
|
|
5
|
+
class SystemMessageDecorator < MessageDecorator
|
|
6
6
|
# @return [nil] system messages are hidden in basic mode
|
|
7
7
|
def render_basic
|
|
8
8
|
nil
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "toon"
|
|
4
4
|
|
|
5
|
-
# Decorates tool_call
|
|
5
|
+
# Decorates tool_call records for display in the TUI.
|
|
6
6
|
# Hidden in basic mode — tool activity is represented by the
|
|
7
7
|
# aggregated tool counter instead. Verbose mode returns tool name
|
|
8
8
|
# and a formatted preview of the input arguments. Debug mode shows
|
|
@@ -12,7 +12,7 @@ require "toon"
|
|
|
12
12
|
# Think tool calls are special: "aloud" thoughts are shown in all
|
|
13
13
|
# view modes (with a thought bubble), while "inner" thoughts are
|
|
14
14
|
# visible only in verbose and debug modes.
|
|
15
|
-
class ToolCallDecorator <
|
|
15
|
+
class ToolCallDecorator < MessageDecorator
|
|
16
16
|
THINK_TOOL = "think"
|
|
17
17
|
|
|
18
18
|
# In basic mode, only "aloud" think calls are visible.
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Base class for server-side tool response decoration. Transforms raw tool
|
|
4
|
-
# results into LLM-optimized formats before they enter the
|
|
4
|
+
# results into LLM-optimized formats before they enter the message stream.
|
|
5
5
|
#
|
|
6
|
-
# This is a separate decorator type from {
|
|
7
|
-
# formats
|
|
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 →
|
|
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
|
|
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 <
|
|
12
|
+
class ToolResponseDecorator < MessageDecorator
|
|
13
13
|
THINK_TOOL = "think"
|
|
14
14
|
|
|
15
15
|
# @return [nil] tool responses are hidden in basic mode
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Decorates user_message
|
|
3
|
+
# Decorates user_message records for display in the TUI.
|
|
4
4
|
# Basic mode returns role and content. Verbose mode adds a timestamp.
|
|
5
5
|
# Debug mode adds token count (exact when counted, estimated when not).
|
|
6
6
|
# Pending messages include `status: "pending"` so the TUI renders them
|
|
7
7
|
# with a visual indicator (dimmed, clock icon).
|
|
8
|
-
class UserMessageDecorator <
|
|
8
|
+
class UserMessageDecorator < MessageDecorator
|
|
9
9
|
# @return [Hash] structured user message data
|
|
10
10
|
# `{role: :user, content: String}` or with `status: "pending"` when queued
|
|
11
11
|
def render_basic
|
|
@@ -36,6 +36,6 @@ class UserMessageDecorator < EventDecorator
|
|
|
36
36
|
|
|
37
37
|
# @return [Boolean] true when this message is queued but not yet sent to LLM
|
|
38
38
|
def pending?
|
|
39
|
-
payload["status"] ==
|
|
39
|
+
payload["status"] == Message::PENDING_STATUS
|
|
40
40
|
end
|
|
41
41
|
end
|
|
@@ -19,9 +19,14 @@ require "toon"
|
|
|
19
19
|
# decorator.call(body: "<h1>Hi</h1>", content_type: "text/html")
|
|
20
20
|
# #=> "[Converted: HTML → Markdown]\n\n# Hi"
|
|
21
21
|
class WebGetToolDecorator < ToolDecorator
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
61
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
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(
|
|
90
|
-
|
|
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]
|