anima-core 1.1.3 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.reek.yml +10 -1
- data/README.md +36 -11
- data/agents/codebase-analyzer.md +2 -2
- data/agents/codebase-pattern-finder.md +2 -2
- data/agents/documentation-researcher.md +2 -2
- data/agents/thoughts-analyzer.md +2 -2
- data/agents/web-search-researcher.md +3 -3
- data/app/channels/session_channel.rb +83 -64
- 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 +6 -6
- data/app/decorators/tool_decorator.rb +4 -4
- data/app/decorators/tool_response_decorator.rb +2 -2
- data/app/decorators/user_message_decorator.rb +5 -19
- data/app/decorators/web_get_tool_decorator.rb +41 -9
- data/app/jobs/agent_request_job.rb +33 -24
- 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 +17 -4
- data/app/models/goal_pinned_message.rb +11 -0
- data/app/models/message.rb +127 -0
- data/app/models/pending_message.rb +43 -0
- data/app/models/pinned_message.rb +41 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +385 -226
- data/app/models/snapshot.rb +25 -25
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
- data/db/migrate/20260328100000_create_secrets.rb +15 -0
- data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
- data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
- data/lib/agent_loop.rb +14 -41
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +40 -37
- 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/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/installer.rb +7 -1
- data/lib/anima/settings.rb +46 -6
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/base.rb +1 -1
- data/lib/events/bounce_back.rb +7 -7
- data/lib/events/subscribers/persister.rb +15 -22
- data/lib/events/subscribers/subagent_message_router.rb +20 -8
- data/lib/events/subscribers/transient_broadcaster.rb +2 -2
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +54 -20
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- 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/shell_session.rb +54 -16
- data/lib/tools/base.rb +23 -0
- data/lib/tools/bash.rb +60 -16
- data/lib/tools/edit.rb +6 -8
- data/lib/tools/mark_goal_completed.rb +86 -0
- data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
- data/lib/tools/read.rb +6 -5
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +37 -8
- data/lib/tools/remember.rb +46 -55
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +15 -25
- data/lib/tools/spawn_subagent.rb +14 -22
- data/lib/tools/subagent_prompts.rb +42 -6
- data/lib/tools/think.rb +26 -10
- data/lib/tools/web_get.rb +23 -4
- data/lib/tools/write.rb +4 -4
- data/lib/tui/app.rb +178 -13
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +4 -4
- data/lib/tui/decorators/base_decorator.rb +17 -8
- data/lib/tui/decorators/bash_decorator.rb +2 -2
- data/lib/tui/decorators/edit_decorator.rb +5 -4
- data/lib/tui/decorators/read_decorator.rb +4 -8
- data/lib/tui/decorators/think_decorator.rb +3 -5
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +5 -4
- data/lib/tui/flash.rb +1 -1
- data/lib/tui/formatting.rb +22 -0
- data/lib/tui/message_store.rb +103 -59
- data/lib/tui/screens/chat.rb +293 -78
- 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 +42 -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 +20 -9
- data/app/jobs/count_event_tokens_job.rb +0 -39
- data/app/models/event.rb +0 -129
- 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
|
@@ -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,17 +2,17 @@
|
|
|
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
|
|
9
9
|
# full untruncated input with tool_use_id — TOON format for most
|
|
10
|
-
# tools, but
|
|
10
|
+
# tools, but write_file tool content preserves actual newlines.
|
|
11
11
|
#
|
|
12
12
|
# Think tool calls are special: "aloud" thoughts are shown in all
|
|
13
13
|
# view modes (with a thought bubble), while "inner" thoughts are
|
|
14
14
|
# visible only in verbose and debug modes.
|
|
15
|
-
class ToolCallDecorator <
|
|
15
|
+
class ToolCallDecorator < MessageDecorator
|
|
16
16
|
THINK_TOOL = "think"
|
|
17
17
|
|
|
18
18
|
# In basic mode, only "aloud" think calls are visible.
|
|
@@ -97,14 +97,14 @@ class ToolCallDecorator < EventDecorator
|
|
|
97
97
|
def format_debug_input
|
|
98
98
|
input = tool_input
|
|
99
99
|
case payload["tool_name"]
|
|
100
|
-
when "
|
|
100
|
+
when "write_file" then format_write_content(input)
|
|
101
101
|
else Toon.encode(input)
|
|
102
102
|
end
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
# Formats write tool input with file path header and content body.
|
|
106
106
|
# Content newlines are preserved so the TUI can render them as
|
|
107
|
-
# separate lines, matching how
|
|
107
|
+
# separate lines, matching how read_file tool responses display file content.
|
|
108
108
|
# @param input [Hash] tool input hash with "file_path" and "content" keys
|
|
109
109
|
# @return [String] path + content with real newlines, or TOON-encoded hash when content is empty
|
|
110
110
|
def format_write_content(input)
|
|
@@ -125,7 +125,7 @@ class ToolCallDecorator < EventDecorator
|
|
|
125
125
|
"$ #{input&.dig("command")}"
|
|
126
126
|
when "web_get"
|
|
127
127
|
"GET #{input&.dig("url")}"
|
|
128
|
-
when "
|
|
128
|
+
when "read_file", "edit_file", "write_file"
|
|
129
129
|
input&.dig("file_path").to_s
|
|
130
130
|
else
|
|
131
131
|
truncate_lines(Toon.encode(input), max_lines: 2)
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Base class for server-side tool response decoration. Transforms raw tool
|
|
4
|
-
# results into LLM-optimized formats before they enter the
|
|
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,24 +1,17 @@
|
|
|
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
|
-
|
|
7
|
-
#
|
|
8
|
-
class UserMessageDecorator < EventDecorator
|
|
9
|
-
# @return [Hash] structured user message data
|
|
10
|
-
# `{role: :user, content: String}` or with `status: "pending"` when queued
|
|
6
|
+
class UserMessageDecorator < MessageDecorator
|
|
7
|
+
# @return [Hash] structured user message data `{role: :user, content: String}`
|
|
11
8
|
def render_basic
|
|
12
|
-
|
|
13
|
-
base[:status] = "pending" if pending?
|
|
14
|
-
base
|
|
9
|
+
{role: :user, content: content}
|
|
15
10
|
end
|
|
16
11
|
|
|
17
12
|
# @return [Hash] structured user message with nanosecond timestamp
|
|
18
13
|
def render_verbose
|
|
19
|
-
|
|
20
|
-
base[:status] = "pending" if pending?
|
|
21
|
-
base
|
|
14
|
+
{role: :user, content: content, timestamp: timestamp}
|
|
22
15
|
end
|
|
23
16
|
|
|
24
17
|
# @return [Hash] verbose output plus token count for debugging
|
|
@@ -31,11 +24,4 @@ class UserMessageDecorator < EventDecorator
|
|
|
31
24
|
def render_brain
|
|
32
25
|
"User: #{truncate_middle(content)}"
|
|
33
26
|
end
|
|
34
|
-
|
|
35
|
-
private
|
|
36
|
-
|
|
37
|
-
# @return [Boolean] true when this message is queued but not yet sent to LLM
|
|
38
|
-
def pending?
|
|
39
|
-
payload["status"] == Event::PENDING_STATUS
|
|
40
|
-
end
|
|
41
27
|
end
|
|
@@ -19,9 +19,14 @@ require "toon"
|
|
|
19
19
|
# decorator.call(body: "<h1>Hi</h1>", content_type: "text/html")
|
|
20
20
|
# #=> "[Converted: HTML → Markdown]\n\n# Hi"
|
|
21
21
|
class WebGetToolDecorator < ToolDecorator
|
|
22
|
-
#
|
|
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]
|
|
@@ -5,18 +5,18 @@
|
|
|
5
5
|
#
|
|
6
6
|
# Supports two modes:
|
|
7
7
|
#
|
|
8
|
-
# **Immediate Persist (
|
|
8
|
+
# **Immediate Persist (message_id provided):** The user message was already
|
|
9
9
|
# persisted and broadcast by the caller (e.g. {SessionChannel#speak}).
|
|
10
10
|
# The job verifies LLM delivery — if the first API call fails, the
|
|
11
|
-
#
|
|
11
|
+
# message is deleted and a {Events::BounceBack} is emitted so clients
|
|
12
12
|
# can restore the text to the input field.
|
|
13
13
|
#
|
|
14
|
-
# **Standard (no
|
|
14
|
+
# **Standard (no message_id):** Processes already-persisted messages (e.g.
|
|
15
15
|
# after pending message promotion). Uses ActiveJob retry/discard for
|
|
16
16
|
# error handling.
|
|
17
17
|
#
|
|
18
|
-
# @example Immediate Persist —
|
|
19
|
-
# AgentRequestJob.perform_later(session.id,
|
|
18
|
+
# @example Immediate Persist — message already saved by SessionChannel
|
|
19
|
+
# AgentRequestJob.perform_later(session.id, message_id: 42)
|
|
20
20
|
#
|
|
21
21
|
# @example Standard — pending message processing
|
|
22
22
|
# AgentRequestJob.perform_later(session.id)
|
|
@@ -49,8 +49,8 @@ class AgentRequestJob < ApplicationJob
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
# @param session_id [Integer] ID of the session to process
|
|
52
|
-
# @param
|
|
53
|
-
def perform(session_id,
|
|
52
|
+
# @param message_id [Integer, nil] ID of a pre-persisted user message (triggers delivery verification)
|
|
53
|
+
def perform(session_id, message_id: nil)
|
|
54
54
|
session = Session.find(session_id)
|
|
55
55
|
|
|
56
56
|
# Atomic: only one job processes a session at a time.
|
|
@@ -60,8 +60,8 @@ class AgentRequestJob < ApplicationJob
|
|
|
60
60
|
|
|
61
61
|
agent_loop = AgentLoop.new(session: session)
|
|
62
62
|
|
|
63
|
-
if
|
|
64
|
-
|
|
63
|
+
if message_id
|
|
64
|
+
deliver_persisted_message(session, message_id, agent_loop)
|
|
65
65
|
else
|
|
66
66
|
agent_loop.run
|
|
67
67
|
end
|
|
@@ -82,11 +82,11 @@ class AgentRequestJob < ApplicationJob
|
|
|
82
82
|
|
|
83
83
|
private
|
|
84
84
|
|
|
85
|
-
# Verifies LLM delivery for a pre-persisted user
|
|
85
|
+
# Verifies LLM delivery for a pre-persisted user message.
|
|
86
86
|
#
|
|
87
|
-
# The
|
|
87
|
+
# The message was already created and broadcast by the caller, so
|
|
88
88
|
# the user sees their message immediately. This method makes the
|
|
89
|
-
# first LLM API call — if it fails, the
|
|
89
|
+
# first LLM API call — if it fails, the message is deleted and a
|
|
90
90
|
# {Events::BounceBack} notifies clients to remove the phantom
|
|
91
91
|
# message and restore the text to the input field. For
|
|
92
92
|
# {Providers::Anthropic::AuthenticationError}, an additional
|
|
@@ -102,26 +102,26 @@ class AgentRequestJob < ApplicationJob
|
|
|
102
102
|
# execution, subsequent API calls).
|
|
103
103
|
#
|
|
104
104
|
# @param session [Session] the conversation session
|
|
105
|
-
# @param
|
|
105
|
+
# @param message_id [Integer] database ID of the pre-persisted user message
|
|
106
106
|
# @param agent_loop [AgentLoop] agent loop instance (reused for continuation)
|
|
107
|
-
def
|
|
108
|
-
|
|
109
|
-
#
|
|
107
|
+
def deliver_persisted_message(session, message_id, agent_loop)
|
|
108
|
+
message = Message.find_by(id: message_id, session_id: session.id)
|
|
109
|
+
# Message may have been deleted between SessionChannel#speak and job
|
|
110
110
|
# execution (e.g. user recalled the message). Exit silently — there
|
|
111
111
|
# is nothing to deliver or bounce back.
|
|
112
|
-
return unless
|
|
112
|
+
return unless message
|
|
113
113
|
|
|
114
|
-
content =
|
|
114
|
+
content = message.payload["content"]
|
|
115
115
|
|
|
116
116
|
begin
|
|
117
117
|
agent_loop.deliver!
|
|
118
118
|
rescue => error
|
|
119
|
-
|
|
119
|
+
message.destroy!
|
|
120
120
|
Events::Bus.emit(Events::BounceBack.new(
|
|
121
121
|
content: content,
|
|
122
122
|
error: error.message,
|
|
123
123
|
session_id: session.id,
|
|
124
|
-
|
|
124
|
+
message_id: message_id
|
|
125
125
|
))
|
|
126
126
|
broadcast_auth_required(session.id, error) if error.is_a?(Providers::Anthropic::AuthenticationError)
|
|
127
127
|
return
|
|
@@ -155,18 +155,27 @@ class AgentRequestJob < ApplicationJob
|
|
|
155
155
|
|
|
156
156
|
# Sets the session's processing flag atomically. Returns true if this
|
|
157
157
|
# job claimed the lock, false if another job already holds it.
|
|
158
|
-
# Broadcasts the state change to
|
|
158
|
+
# Broadcasts +session_state: llm_generating+ and the state change to
|
|
159
|
+
# the parent session's HUD.
|
|
159
160
|
def claim_processing(session_id)
|
|
160
161
|
claimed = Session.where(id: session_id, processing: false).update_all(processing: true) == 1
|
|
161
|
-
|
|
162
|
+
if claimed
|
|
163
|
+
session = Session.find_by(id: session_id)
|
|
164
|
+
session&.broadcast_session_state("llm_generating")
|
|
165
|
+
session&.broadcast_children_update_to_parent
|
|
166
|
+
end
|
|
162
167
|
claimed
|
|
163
168
|
end
|
|
164
169
|
|
|
165
170
|
# Clears the processing flag so the session can accept new jobs.
|
|
166
|
-
# Broadcasts
|
|
171
|
+
# Broadcasts +session_state: idle+ to the session stream (replaces
|
|
172
|
+
# the old +processing_stopped+ action) and +children_updated+ to the
|
|
173
|
+
# parent session's HUD.
|
|
167
174
|
def release_processing(session_id)
|
|
168
175
|
Session.where(id: session_id).update_all(processing: false)
|
|
169
|
-
Session.find_by(id: session_id)
|
|
176
|
+
session = Session.find_by(id: session_id)
|
|
177
|
+
session&.broadcast_session_state("idle")
|
|
178
|
+
session&.broadcast_children_update_to_parent
|
|
170
179
|
end
|
|
171
180
|
|
|
172
181
|
# Safety-net clearing of the interrupt flag.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Counts tokens in a message's payload via the Anthropic API and
|
|
4
|
+
# caches the result on the message record. Enqueued automatically
|
|
5
|
+
# after each LLM message is created.
|
|
6
|
+
class CountMessageTokensJob < ApplicationJob
|
|
7
|
+
queue_as :default
|
|
8
|
+
|
|
9
|
+
retry_on Providers::Anthropic::Error, wait: :polynomially_longer, attempts: 3
|
|
10
|
+
discard_on ActiveRecord::RecordNotFound
|
|
11
|
+
|
|
12
|
+
# @param message_id [Integer] the Message record to count tokens for
|
|
13
|
+
def perform(message_id)
|
|
14
|
+
message = Message.find(message_id)
|
|
15
|
+
return if already_counted?(message)
|
|
16
|
+
|
|
17
|
+
provider = Providers::Anthropic.new
|
|
18
|
+
api_messages = [{role: message.api_role, content: message.payload["content"].to_s}]
|
|
19
|
+
|
|
20
|
+
token_count = provider.count_tokens(
|
|
21
|
+
model: Anima::Settings.model,
|
|
22
|
+
messages: api_messages
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Guard against parallel jobs: reload and re-check before writing.
|
|
26
|
+
# Uses update! (not update_all) so {Message::Broadcasting} after_update_commit
|
|
27
|
+
# broadcasts the updated token count to connected clients.
|
|
28
|
+
message.reload
|
|
29
|
+
return if already_counted?(message)
|
|
30
|
+
|
|
31
|
+
message.update!(token_count: token_count)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def already_counted?(message)
|
|
37
|
+
message.token_count > 0
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Runs passive recall after goal updates — searches
|
|
3
|
+
# Runs passive recall after goal updates — searches message history for
|
|
4
4
|
# context relevant to active goals and caches results on the session
|
|
5
5
|
# for viewport injection.
|
|
6
6
|
#
|
|
@@ -20,10 +20,10 @@ class PassiveRecallJob < ApplicationJob
|
|
|
20
20
|
results = Mneme::PassiveRecall.new(session).call
|
|
21
21
|
|
|
22
22
|
if results.any?
|
|
23
|
-
session.update_column(:
|
|
23
|
+
session.update_column(:recalled_message_ids, results.map(&:message_id))
|
|
24
24
|
Mneme.logger.info("session=#{session_id} — passive recall found #{results.size} memories")
|
|
25
|
-
elsif session.
|
|
26
|
-
session.update_column(:
|
|
25
|
+
elsif session.recalled_message_ids.present?
|
|
26
|
+
session.update_column(:recalled_message_ids, [])
|
|
27
27
|
end
|
|
28
28
|
end
|
|
29
29
|
end
|