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
|
@@ -3,12 +3,17 @@
|
|
|
3
3
|
module Events
|
|
4
4
|
module Subscribers
|
|
5
5
|
# Persists all events to SQLite as they flow through the event bus.
|
|
6
|
-
# Each event is written as
|
|
6
|
+
# Each event is written as a Message record belonging to the active session.
|
|
7
7
|
#
|
|
8
8
|
# When initialized with a specific session, all events are saved to that
|
|
9
9
|
# session. When initialized without one (global mode), the session is
|
|
10
10
|
# looked up from the event's session_id payload field.
|
|
11
11
|
#
|
|
12
|
+
# User messages are NOT persisted here — they are created directly by
|
|
13
|
+
# their callers ({SessionChannel#speak}, {AgentLoop#run}) so the
|
|
14
|
+
# message ID is available for bounce-back cleanup. Pending user
|
|
15
|
+
# messages live in the {PendingMessage} table, outside the event bus.
|
|
16
|
+
#
|
|
12
17
|
# @example Session-scoped
|
|
13
18
|
# persister = Events::Subscribers::Persister.new(session)
|
|
14
19
|
# Events::Bus.subscribe(persister)
|
|
@@ -28,10 +33,10 @@ module Events
|
|
|
28
33
|
|
|
29
34
|
# Receives a Rails.event notification hash and persists it.
|
|
30
35
|
#
|
|
31
|
-
# Skips
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
36
|
+
# Skips user messages — those are persisted by their callers
|
|
37
|
+
# ({SessionChannel#speak}, {AgentLoop#run}). Also skips event
|
|
38
|
+
# types not in {Message::TYPES} (transient events like
|
|
39
|
+
# {Events::BounceBack}).
|
|
35
40
|
#
|
|
36
41
|
# @param event [Hash] with :payload containing event data
|
|
37
42
|
def emit(event)
|
|
@@ -40,19 +45,18 @@ module Events
|
|
|
40
45
|
|
|
41
46
|
event_type = payload[:type]
|
|
42
47
|
return if event_type.nil?
|
|
43
|
-
return unless
|
|
44
|
-
return if
|
|
48
|
+
return unless Message::TYPES.include?(event_type)
|
|
49
|
+
return if event_type == "user_message"
|
|
45
50
|
|
|
46
51
|
target_session = @session || Session.find_by(id: payload[:session_id])
|
|
47
52
|
return unless target_session
|
|
48
53
|
|
|
49
54
|
@mutex.synchronize do
|
|
50
|
-
target_session.
|
|
51
|
-
|
|
55
|
+
target_session.messages.create!(
|
|
56
|
+
message_type: event_type,
|
|
52
57
|
payload: payload,
|
|
53
|
-
status: payload[:status],
|
|
54
58
|
tool_use_id: payload[:tool_use_id],
|
|
55
|
-
timestamp: payload[:timestamp] ||
|
|
59
|
+
timestamp: payload[:timestamp] || Time.current.to_ns
|
|
56
60
|
)
|
|
57
61
|
end
|
|
58
62
|
end
|
|
@@ -60,17 +64,6 @@ module Events
|
|
|
60
64
|
def session=(new_session)
|
|
61
65
|
@mutex.synchronize { @session = new_session }
|
|
62
66
|
end
|
|
63
|
-
|
|
64
|
-
private
|
|
65
|
-
|
|
66
|
-
# Non-pending user messages are persisted by their callers
|
|
67
|
-
# ({SessionChannel#speak}, {AgentLoop#process}) so the event ID
|
|
68
|
-
# is available for bounce-back cleanup if LLM delivery fails.
|
|
69
|
-
# Pending messages are still auto-persisted here because they
|
|
70
|
-
# queue while the session is busy.
|
|
71
|
-
def persisted_by_job?(event_type, payload)
|
|
72
|
-
event_type == "user_message" && payload[:status] != Event::PENDING_STATUS
|
|
73
|
-
end
|
|
74
67
|
end
|
|
75
68
|
end
|
|
76
69
|
end
|
|
@@ -14,7 +14,8 @@ module Events
|
|
|
14
14
|
#
|
|
15
15
|
# **Parent → Child:** When a parent agent emits an {Events::AgentMessage}
|
|
16
16
|
# containing `@name` mentions, the router persists the message in each
|
|
17
|
-
# matching child session
|
|
17
|
+
# matching child session with a +[from parent]:+ origin label and wakes
|
|
18
|
+
# them via {AgentRequestJob}.
|
|
18
19
|
#
|
|
19
20
|
# Both directions delegate to {Session#enqueue_user_message}, which
|
|
20
21
|
# respects the target session's processing state — persisting directly
|
|
@@ -25,9 +26,12 @@ module Events
|
|
|
25
26
|
class SubagentMessageRouter
|
|
26
27
|
include Events::Subscriber
|
|
27
28
|
|
|
28
|
-
#
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
# @see Tools::ResponseTruncator::ATTRIBUTION_FORMAT
|
|
30
|
+
ATTRIBUTION_FORMAT = Tools::ResponseTruncator::ATTRIBUTION_FORMAT
|
|
31
|
+
|
|
32
|
+
# Origin label for messages routed from parent agent to sub-agent.
|
|
33
|
+
# Lets the sub-agent distinguish delegated work from direct user input.
|
|
34
|
+
PARENT_ATTRIBUTION_FORMAT = "[from parent]: %s"
|
|
31
35
|
|
|
32
36
|
# Regex to extract @mention names from parent agent messages.
|
|
33
37
|
MENTION_PATTERN = /@(\w[\w-]*)/
|
|
@@ -64,7 +68,8 @@ module Events
|
|
|
64
68
|
private
|
|
65
69
|
|
|
66
70
|
# Forwards a sub-agent's text message to its parent session
|
|
67
|
-
# via {Session#enqueue_user_message}.
|
|
71
|
+
# via {Session#enqueue_user_message}. Truncates oversized messages
|
|
72
|
+
# to protect the parent's context window.
|
|
68
73
|
#
|
|
69
74
|
# @param child [Session] the sub-agent session
|
|
70
75
|
# @param content [String] the sub-agent's message text
|
|
@@ -73,13 +78,18 @@ module Events
|
|
|
73
78
|
return unless parent
|
|
74
79
|
|
|
75
80
|
name = child.name || "agent-#{child.id}"
|
|
76
|
-
|
|
81
|
+
truncated = Tools::ResponseTruncator.truncate(
|
|
82
|
+
content,
|
|
83
|
+
threshold: Anima::Settings.max_subagent_response_chars,
|
|
84
|
+
reason: "sub-agent output displays first/last #{Tools::ResponseTruncator::HEAD_LINES} lines"
|
|
85
|
+
)
|
|
86
|
+
attributed = format(ATTRIBUTION_FORMAT, name, truncated)
|
|
77
87
|
|
|
78
88
|
parent.enqueue_user_message(attributed)
|
|
79
89
|
end
|
|
80
90
|
|
|
81
91
|
# Scans a parent agent's message for @mentions and routes the message
|
|
82
|
-
# to each mentioned child session.
|
|
92
|
+
# to each mentioned child session with origin attribution.
|
|
83
93
|
#
|
|
84
94
|
# @param parent [Session] the parent session
|
|
85
95
|
# @param content [String] the parent agent's message text
|
|
@@ -90,11 +100,13 @@ module Events
|
|
|
90
100
|
active_children = parent.child_sessions.where.not(name: nil).index_by(&:name)
|
|
91
101
|
return if active_children.empty?
|
|
92
102
|
|
|
103
|
+
attributed = format(PARENT_ATTRIBUTION_FORMAT, content)
|
|
104
|
+
|
|
93
105
|
mentioned_names.each do |name|
|
|
94
106
|
child = active_children[name]
|
|
95
107
|
next unless child
|
|
96
108
|
|
|
97
|
-
child.enqueue_user_message(
|
|
109
|
+
child.enqueue_user_message(attributed)
|
|
98
110
|
end
|
|
99
111
|
end
|
|
100
112
|
end
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module Events
|
|
4
4
|
module Subscribers
|
|
5
5
|
# Bridges transient (non-persisted) events to ActionCable so clients
|
|
6
|
-
# receive them over WebSocket. Persisted
|
|
7
|
-
# {
|
|
6
|
+
# receive them over WebSocket. Persisted messages reach clients via
|
|
7
|
+
# {Message::Broadcasting} callbacks; this subscriber handles events
|
|
8
8
|
# that never touch the database.
|
|
9
9
|
#
|
|
10
10
|
# @example Registering at boot
|
data/lib/events/user_message.rb
CHANGED
|
@@ -4,25 +4,14 @@ module Events
|
|
|
4
4
|
class UserMessage < Base
|
|
5
5
|
TYPE = "user_message"
|
|
6
6
|
|
|
7
|
-
# @return [String, nil] "pending" when queued during active processing, nil otherwise
|
|
8
|
-
attr_reader :status
|
|
9
|
-
|
|
10
7
|
# @param content [String] message text
|
|
11
8
|
# @param session_id [Integer, nil] session identifier
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
super(content: content, session_id: session_id)
|
|
15
|
-
@status = status
|
|
9
|
+
def initialize(content:, session_id: nil)
|
|
10
|
+
super
|
|
16
11
|
end
|
|
17
12
|
|
|
18
13
|
def type
|
|
19
14
|
TYPE
|
|
20
15
|
end
|
|
21
|
-
|
|
22
|
-
def to_h
|
|
23
|
-
h = super
|
|
24
|
-
h[:status] = status if status
|
|
25
|
-
h
|
|
26
|
-
end
|
|
27
16
|
end
|
|
28
17
|
end
|
data/lib/llm/client.rb
CHANGED
|
@@ -15,8 +15,8 @@ module LLM
|
|
|
15
15
|
# registry.register(Tools::WebGet)
|
|
16
16
|
# client.chat_with_tools(messages, registry: registry, session_id: session.id)
|
|
17
17
|
class Client
|
|
18
|
-
# Synthetic tool_result
|
|
19
|
-
INTERRUPT_MESSAGE = "
|
|
18
|
+
# Synthetic tool_result when a tool is skipped because the human pressed Escape.
|
|
19
|
+
INTERRUPT_MESSAGE = "Your human wants your attention"
|
|
20
20
|
|
|
21
21
|
# @return [Providers::Anthropic] the underlying API provider
|
|
22
22
|
attr_reader :provider
|
|
@@ -65,7 +65,7 @@ module LLM
|
|
|
65
65
|
# tool interaction so they're persisted and visible in the event stream.
|
|
66
66
|
#
|
|
67
67
|
# When the user interrupts via Escape, remaining tools receive synthetic
|
|
68
|
-
# "
|
|
68
|
+
# "Your human wants your attention" results and the loop exits without another LLM call.
|
|
69
69
|
#
|
|
70
70
|
# @param messages [Array<Hash>] conversation messages in Anthropic format
|
|
71
71
|
# @param registry [Tools::Registry] registered tools to make available
|
|
@@ -90,6 +90,7 @@ module LLM
|
|
|
90
90
|
response = if first_response && rounds == 1
|
|
91
91
|
first_response
|
|
92
92
|
else
|
|
93
|
+
broadcast_session_state(session_id, "llm_generating")
|
|
93
94
|
provider.create_message(
|
|
94
95
|
model: model,
|
|
95
96
|
messages: messages,
|
|
@@ -109,11 +110,14 @@ module LLM
|
|
|
109
110
|
{role: "user", content: tool_results}
|
|
110
111
|
]
|
|
111
112
|
|
|
112
|
-
if
|
|
113
|
-
clear_interrupt!(session_id)
|
|
114
|
-
return nil
|
|
115
|
-
end
|
|
113
|
+
return nil if handle_interrupt!(session_id)
|
|
116
114
|
else
|
|
115
|
+
# Discard the text response if the user pressed Escape while
|
|
116
|
+
# the API was generating it. Without this check the interrupt
|
|
117
|
+
# flag set during the blocking API call would be silently
|
|
118
|
+
# cleared by the ensure block in AgentRequestJob.
|
|
119
|
+
return nil if handle_interrupt!(session_id)
|
|
120
|
+
|
|
117
121
|
return extract_text(response)
|
|
118
122
|
end
|
|
119
123
|
end
|
|
@@ -151,9 +155,12 @@ module LLM
|
|
|
151
155
|
def execute_tools(response, registry, session_id)
|
|
152
156
|
tool_uses = extract_tool_uses(response)
|
|
153
157
|
results = []
|
|
158
|
+
interrupted = false
|
|
154
159
|
|
|
155
160
|
tool_uses.each_with_index do |tool_use, index|
|
|
156
|
-
|
|
161
|
+
# Check-only here; clearing happens in handle_interrupt! after the loop
|
|
162
|
+
interrupted ||= interrupt_requested?(session_id)
|
|
163
|
+
if interrupted
|
|
157
164
|
remaining = tool_uses[index..]
|
|
158
165
|
results.concat(interrupt_remaining_tools(remaining, session_id)) if remaining&.any?
|
|
159
166
|
break
|
|
@@ -164,7 +171,7 @@ module LLM
|
|
|
164
171
|
results
|
|
165
172
|
end
|
|
166
173
|
|
|
167
|
-
# Creates synthetic "
|
|
174
|
+
# Creates synthetic "Your human wants your attention" results for all tools in the list.
|
|
168
175
|
#
|
|
169
176
|
# @param tool_uses [Array<Hash>] remaining tool_use content blocks
|
|
170
177
|
# @param session_id [Integer, String] session ID for events
|
|
@@ -188,6 +195,8 @@ module LLM
|
|
|
188
195
|
|
|
189
196
|
log(:debug, "tool_call: #{name}(#{input.to_json})")
|
|
190
197
|
|
|
198
|
+
broadcast_session_state(session_id, "tool_executing", tool: name)
|
|
199
|
+
|
|
191
200
|
Events::Bus.emit(Events::ToolCall.new(
|
|
192
201
|
content: "Calling #{name}", tool_name: name,
|
|
193
202
|
tool_input: input, tool_use_id: id, timeout: timeout,
|
|
@@ -197,6 +206,7 @@ module LLM
|
|
|
197
206
|
result = registry.execute(name, input)
|
|
198
207
|
result = ToolDecorator.call(name, result)
|
|
199
208
|
result_content = format_tool_result(result)
|
|
209
|
+
result_content = truncate_tool_result(result_content, registry, name)
|
|
200
210
|
log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
|
|
201
211
|
|
|
202
212
|
Events::Bus.emit(Events::ToolResponse.new(
|
|
@@ -225,7 +235,7 @@ module LLM
|
|
|
225
235
|
{type: "tool_result", tool_use_id: id, content: error_content}
|
|
226
236
|
end
|
|
227
237
|
|
|
228
|
-
# Creates a synthetic "
|
|
238
|
+
# Creates a synthetic "Your human wants your attention" result for a tool that was not
|
|
229
239
|
# executed due to user interrupt. Emits both ToolCall and ToolResponse
|
|
230
240
|
# events so the TUI shows the interrupted tool in the event stream.
|
|
231
241
|
#
|
|
@@ -238,7 +248,7 @@ module LLM
|
|
|
238
248
|
input = tool_use["input"] || {}
|
|
239
249
|
|
|
240
250
|
Events::Bus.emit(Events::ToolCall.new(
|
|
241
|
-
content: "Skipped #{name}
|
|
251
|
+
content: "Skipped #{name} — your human wants your attention", tool_name: name,
|
|
242
252
|
tool_input: input, tool_use_id: id, session_id: session_id
|
|
243
253
|
))
|
|
244
254
|
|
|
@@ -250,22 +260,35 @@ module LLM
|
|
|
250
260
|
{type: "tool_result", tool_use_id: id, content: INTERRUPT_MESSAGE}
|
|
251
261
|
end
|
|
252
262
|
|
|
253
|
-
# Checks the
|
|
263
|
+
# Checks whether the session has a pending interrupt flag.
|
|
254
264
|
#
|
|
255
265
|
# @param session_id [Integer, String] session to check
|
|
256
|
-
# @return [Boolean]
|
|
257
|
-
def
|
|
266
|
+
# @return [Boolean] true when interrupt is pending
|
|
267
|
+
def interrupt_requested?(session_id)
|
|
258
268
|
Session.where(id: session_id, interrupt_requested: true).exists?
|
|
259
269
|
end
|
|
260
270
|
|
|
261
|
-
#
|
|
262
|
-
#
|
|
263
|
-
#
|
|
271
|
+
# Atomically checks for a pending interrupt and clears it in one query.
|
|
272
|
+
# Used at loop boundaries (after tools, before LLM text return) to
|
|
273
|
+
# short-circuit the agent loop when the user presses Escape.
|
|
264
274
|
#
|
|
265
|
-
# @param session_id [Integer, String] session to
|
|
275
|
+
# @param session_id [Integer, String] session to check
|
|
276
|
+
# @return [Boolean] true when interrupt was detected and cleared
|
|
277
|
+
def handle_interrupt!(session_id)
|
|
278
|
+
Session.where(id: session_id, interrupt_requested: true)
|
|
279
|
+
.update_all(interrupt_requested: false) > 0
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Broadcasts a session state transition to all subscribed clients.
|
|
283
|
+
# Delegates to {Session#broadcast_session_state} which handles both
|
|
284
|
+
# the session's own stream and the parent's stream for HUD updates.
|
|
285
|
+
#
|
|
286
|
+
# @param session_id [Integer, String] session to broadcast for
|
|
287
|
+
# @param state [String] one of "idle", "llm_generating", "tool_executing", "interrupting"
|
|
288
|
+
# @param tool [String, nil] tool name when state is "tool_executing"
|
|
266
289
|
# @return [void]
|
|
267
|
-
def
|
|
268
|
-
Session.
|
|
290
|
+
def broadcast_session_state(session_id, state, tool: nil)
|
|
291
|
+
Session.find_by(id: session_id)&.broadcast_session_state(state, tool: tool)
|
|
269
292
|
end
|
|
270
293
|
|
|
271
294
|
def log(level, message)
|
|
@@ -277,5 +300,16 @@ module LLM
|
|
|
277
300
|
def format_tool_result(result)
|
|
278
301
|
result.is_a?(Hash) ? result.to_json : result.to_s
|
|
279
302
|
end
|
|
303
|
+
|
|
304
|
+
# Applies head+tail truncation when a tool result exceeds the tool's
|
|
305
|
+
# configured character threshold. Skips tools that opt out (e.g. read).
|
|
306
|
+
def truncate_tool_result(content, registry, tool_name)
|
|
307
|
+
threshold = registry.truncation_threshold(tool_name)
|
|
308
|
+
return content unless threshold
|
|
309
|
+
|
|
310
|
+
lines = Tools::ResponseTruncator::HEAD_LINES
|
|
311
|
+
reason = "#{tool_name} output displays first/last #{lines} lines"
|
|
312
|
+
Tools::ResponseTruncator.truncate(content, threshold: threshold, reason: reason)
|
|
313
|
+
end
|
|
280
314
|
end
|
|
281
315
|
end
|
data/lib/mcp/config.rb
CHANGED
|
@@ -6,7 +6,7 @@ require "toml-rb"
|
|
|
6
6
|
module Mcp
|
|
7
7
|
# Reads and writes MCP server configuration from a TOML file at
|
|
8
8
|
# {DEFAULT_PATH}. Supports HTTP and stdio transports. Secrets stored
|
|
9
|
-
# in
|
|
9
|
+
# in the encrypted secrets table are interpolated via
|
|
10
10
|
# +${credential:key_name}+ syntax in any string value.
|
|
11
11
|
#
|
|
12
12
|
# @example Config file format (~/.anima/mcp.toml)
|
|
@@ -187,7 +187,7 @@ module Mcp
|
|
|
187
187
|
end
|
|
188
188
|
|
|
189
189
|
# Replaces +${credential:key_name}+ placeholders with values from
|
|
190
|
-
#
|
|
190
|
+
# the encrypted secrets table via {Mcp::Secrets}.
|
|
191
191
|
#
|
|
192
192
|
# @param value [String] string potentially containing placeholders
|
|
193
193
|
# @return [String] interpolated string
|
data/lib/mcp/secrets.rb
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Mcp
|
|
4
|
-
# CRUD operations for MCP server secrets stored in
|
|
5
|
-
# Secrets live under the +mcp+ namespace
|
|
4
|
+
# CRUD operations for MCP server secrets stored in the encrypted secrets table.
|
|
5
|
+
# Secrets live under the +mcp+ namespace:
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# mythonix_api_key: "Bearer tok-yyy"
|
|
7
|
+
# Mcp::Secrets.set("linear_api_key", "sk-xxx")
|
|
8
|
+
# Mcp::Secrets.get("linear_api_key") #=> "sk-xxx"
|
|
10
9
|
#
|
|
11
10
|
# Referenced in mcp.toml via +${credential:key_name}+ syntax, resolved at
|
|
12
11
|
# runtime by {Mcp::Config#interpolate_credentials}.
|
|
@@ -23,7 +22,7 @@ module Mcp
|
|
|
23
22
|
VALID_KEY_PATTERN = /\A\w+\z/
|
|
24
23
|
|
|
25
24
|
class << self
|
|
26
|
-
# Stores a secret in encrypted
|
|
25
|
+
# Stores a secret in encrypted storage.
|
|
27
26
|
#
|
|
28
27
|
# @param key [String] secret identifier (e.g. "linear_api_key")
|
|
29
28
|
# @param value [String] secret value
|
|
@@ -35,7 +34,7 @@ module Mcp
|
|
|
35
34
|
CredentialStore.write(NAMESPACE, key => value)
|
|
36
35
|
end
|
|
37
36
|
|
|
38
|
-
# Retrieves a secret from encrypted
|
|
37
|
+
# Retrieves a secret from encrypted storage.
|
|
39
38
|
#
|
|
40
39
|
# @param key [String] secret identifier
|
|
41
40
|
# @return [String, nil] secret value or nil if not found
|
|
@@ -50,7 +49,7 @@ module Mcp
|
|
|
50
49
|
CredentialStore.list(NAMESPACE)
|
|
51
50
|
end
|
|
52
51
|
|
|
53
|
-
# Removes a secret from encrypted
|
|
52
|
+
# Removes a secret from encrypted storage.
|
|
54
53
|
#
|
|
55
54
|
# @param key [String] secret identifier to remove
|
|
56
55
|
# @return [void]
|
|
@@ -7,16 +7,16 @@ module Mneme
|
|
|
7
7
|
# aggregate counters like `[4 tools called]`.
|
|
8
8
|
#
|
|
9
9
|
# The viewport is split into three zones separated by delimiters:
|
|
10
|
-
# - **Eviction zone** —
|
|
11
|
-
# - **Middle zone** —
|
|
12
|
-
# - **Recent zone** — the most recent
|
|
10
|
+
# - **Eviction zone** — messages about to leave the viewport (upper third)
|
|
11
|
+
# - **Middle zone** — messages in the middle of the viewport
|
|
12
|
+
# - **Recent zone** — the most recent messages (lower third)
|
|
13
13
|
#
|
|
14
14
|
# Zone boundaries are calculated WITH tool call tokens (they affect
|
|
15
15
|
# position), then tool calls are removed and replaced with counters.
|
|
16
16
|
#
|
|
17
17
|
# @example
|
|
18
18
|
# viewport = Mneme::CompressedViewport.new(session, token_budget: 60_000)
|
|
19
|
-
# viewport.render #=> "── EVICTION ZONE ──\
|
|
19
|
+
# viewport.render #=> "── EVICTION ZONE ──\nmessage 42 User: ..."
|
|
20
20
|
class CompressedViewport
|
|
21
21
|
ZONE_DELIMITERS = {
|
|
22
22
|
eviction: "── EVICTION ZONE (upper third) ──",
|
|
@@ -26,74 +26,74 @@ module Mneme
|
|
|
26
26
|
|
|
27
27
|
# @param session [Session] the session to build viewport for
|
|
28
28
|
# @param token_budget [Integer] total tokens available for Mneme's viewport
|
|
29
|
-
# @param
|
|
29
|
+
# @param from_message_id [Integer, nil] start from this message ID (inclusive);
|
|
30
30
|
# when nil, uses the session's full viewport
|
|
31
|
-
def initialize(session, token_budget:,
|
|
31
|
+
def initialize(session, token_budget:, from_message_id: nil)
|
|
32
32
|
@session = session
|
|
33
33
|
@token_budget = token_budget
|
|
34
|
-
@
|
|
34
|
+
@from_message_id = from_message_id
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
# Renders the compressed viewport as a string ready for Mneme's LLM context.
|
|
38
38
|
#
|
|
39
39
|
# @return [String] compressed viewport with zone delimiters
|
|
40
40
|
def render
|
|
41
|
-
return "" if
|
|
41
|
+
return "" if messages.empty?
|
|
42
42
|
|
|
43
|
-
zones = split_into_zones(
|
|
43
|
+
zones = split_into_zones(messages)
|
|
44
44
|
render_zones(zones)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
# @return [Array<
|
|
48
|
-
def
|
|
49
|
-
@
|
|
47
|
+
# @return [Array<Message>] the raw messages selected for this viewport
|
|
48
|
+
def messages
|
|
49
|
+
@messages ||= fetch_messages
|
|
50
50
|
end
|
|
51
51
|
|
|
52
52
|
private
|
|
53
53
|
|
|
54
|
-
# Fetches
|
|
54
|
+
# Fetches messages within token budget, starting from from_message_id.
|
|
55
55
|
# Selects newest-first until budget exhausted, returns chronological.
|
|
56
|
-
# Caches per-
|
|
56
|
+
# Caches per-message token costs in @message_costs for reuse by split_into_zones.
|
|
57
57
|
#
|
|
58
|
-
# @return [Array<
|
|
59
|
-
def
|
|
60
|
-
scope = @session.
|
|
58
|
+
# @return [Array<Message>]
|
|
59
|
+
def fetch_messages
|
|
60
|
+
scope = @session.messages.context_messages
|
|
61
61
|
|
|
62
|
-
if @
|
|
63
|
-
scope = scope.where("id >= ?", @
|
|
62
|
+
if @from_message_id
|
|
63
|
+
scope = scope.where("id >= ?", @from_message_id)
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
selected = []
|
|
67
|
-
@
|
|
67
|
+
@message_costs = {}
|
|
68
68
|
remaining = @token_budget
|
|
69
69
|
|
|
70
|
-
scope.reorder(id: :desc).each do |
|
|
71
|
-
cost =
|
|
70
|
+
scope.reorder(id: :desc).each do |message|
|
|
71
|
+
cost = message_token_cost(message)
|
|
72
72
|
break if cost > remaining && selected.any?
|
|
73
73
|
|
|
74
|
-
selected <<
|
|
75
|
-
@
|
|
74
|
+
selected << message
|
|
75
|
+
@message_costs[message.id] = cost
|
|
76
76
|
remaining -= cost
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
selected.reverse
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
# Splits
|
|
83
|
-
# Zone boundaries are calculated including ALL
|
|
82
|
+
# Splits messages into three zones by token count.
|
|
83
|
+
# Zone boundaries are calculated including ALL messages (tool calls count
|
|
84
84
|
# toward position), but zone assignment uses cumulative tokens.
|
|
85
85
|
#
|
|
86
|
-
# @return [Hash{Symbol => Array<
|
|
87
|
-
def split_into_zones(
|
|
88
|
-
costs =
|
|
86
|
+
# @return [Hash{Symbol => Array<Message>}] :eviction, :middle, :recent
|
|
87
|
+
def split_into_zones(messages)
|
|
88
|
+
costs = messages.map { |message| [message, @message_costs[message.id] || message_token_cost(message)] }
|
|
89
89
|
zone_size = costs.sum(&:last) / 3.0
|
|
90
90
|
|
|
91
91
|
result = {eviction: [], middle: [], recent: []}
|
|
92
92
|
cumulative = 0
|
|
93
93
|
|
|
94
|
-
costs.each do |
|
|
94
|
+
costs.each do |message, cost|
|
|
95
95
|
cumulative += cost
|
|
96
|
-
result[zone_for_cumulative(cumulative, zone_size)] <<
|
|
96
|
+
result[zone_for_cumulative(cumulative, zone_size)] << message
|
|
97
97
|
end
|
|
98
98
|
|
|
99
99
|
result
|
|
@@ -101,7 +101,7 @@ module Mneme
|
|
|
101
101
|
|
|
102
102
|
# Renders zones with delimiters, compressing tool calls into counters.
|
|
103
103
|
#
|
|
104
|
-
# @param zones [Hash{Symbol => Array<
|
|
104
|
+
# @param zones [Hash{Symbol => Array<Message>}]
|
|
105
105
|
# @return [String]
|
|
106
106
|
def render_zones(zones)
|
|
107
107
|
%i[eviction middle recent].flat_map { |name|
|
|
@@ -124,23 +124,23 @@ module Mneme
|
|
|
124
124
|
end
|
|
125
125
|
end
|
|
126
126
|
|
|
127
|
-
# Renders a single zone: conversation
|
|
127
|
+
# Renders a single zone: conversation messages as full text, consecutive
|
|
128
128
|
# tool calls/responses compressed into `[N tools called]` counters.
|
|
129
|
-
# tool_response
|
|
130
|
-
# via token cost but are not rendered; only tool_call
|
|
129
|
+
# tool_response messages are intentionally silent — they affect zone boundaries
|
|
130
|
+
# via token cost but are not rendered; only tool_call messages increment the counter.
|
|
131
131
|
#
|
|
132
|
-
# @param
|
|
132
|
+
# @param zone_messages [Array<Message>]
|
|
133
133
|
# @return [String]
|
|
134
|
-
def render_zone(
|
|
134
|
+
def render_zone(zone_messages)
|
|
135
135
|
lines = []
|
|
136
136
|
tool_count = 0
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
if
|
|
138
|
+
zone_messages.each do |message|
|
|
139
|
+
if conversation_message?(message) || think_message?(message)
|
|
140
140
|
lines << flush_tool_count(tool_count)
|
|
141
141
|
tool_count = 0
|
|
142
|
-
lines <<
|
|
143
|
-
elsif
|
|
142
|
+
lines << render_message_line(message)
|
|
143
|
+
elsif message.message_type == "tool_call"
|
|
144
144
|
tool_count += 1
|
|
145
145
|
end
|
|
146
146
|
end
|
|
@@ -149,17 +149,17 @@ module Mneme
|
|
|
149
149
|
lines.compact.join("\n")
|
|
150
150
|
end
|
|
151
151
|
|
|
152
|
-
# @return [Boolean] true if
|
|
153
|
-
def
|
|
154
|
-
|
|
152
|
+
# @return [Boolean] true if message is a user/agent/system message
|
|
153
|
+
def conversation_message?(message)
|
|
154
|
+
message.message_type.in?(Message::CONVERSATION_TYPES)
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
-
# Think
|
|
157
|
+
# Think messages are tool_call messages with tool_name == "think".
|
|
158
158
|
# They carry the agent's reasoning and are treated as conversation.
|
|
159
159
|
#
|
|
160
160
|
# @return [Boolean]
|
|
161
|
-
def
|
|
162
|
-
|
|
161
|
+
def think_message?(message)
|
|
162
|
+
message.message_type == "tool_call" && message.payload["tool_name"] == Message::THINK_TOOL
|
|
163
163
|
end
|
|
164
164
|
|
|
165
165
|
ROLE_LABELS = {
|
|
@@ -168,17 +168,17 @@ module Mneme
|
|
|
168
168
|
"system_message" => "System"
|
|
169
169
|
}.freeze
|
|
170
170
|
|
|
171
|
-
# Renders a single
|
|
171
|
+
# Renders a single message as a transcript line.
|
|
172
172
|
#
|
|
173
|
-
# @param
|
|
173
|
+
# @param message [Message]
|
|
174
174
|
# @return [String]
|
|
175
|
-
def
|
|
176
|
-
prefix = "
|
|
177
|
-
data =
|
|
178
|
-
if
|
|
175
|
+
def render_message_line(message)
|
|
176
|
+
prefix = "message #{message.id}"
|
|
177
|
+
data = message.payload
|
|
178
|
+
if think_message?(message)
|
|
179
179
|
"#{prefix} Think: #{data.dig("tool_input", "thoughts")}"
|
|
180
180
|
else
|
|
181
|
-
"#{prefix} #{ROLE_LABELS.fetch(
|
|
181
|
+
"#{prefix} #{ROLE_LABELS.fetch(message.message_type)}: #{data["content"]}"
|
|
182
182
|
end
|
|
183
183
|
end
|
|
184
184
|
|
|
@@ -192,9 +192,9 @@ module Mneme
|
|
|
192
192
|
end
|
|
193
193
|
|
|
194
194
|
# @return [Integer] token cost using cached count or heuristic
|
|
195
|
-
def
|
|
196
|
-
cached =
|
|
197
|
-
(cached > 0) ? cached :
|
|
195
|
+
def message_token_cost(message)
|
|
196
|
+
cached = message.token_count
|
|
197
|
+
(cached > 0) ? cached : message.estimate_tokens
|
|
198
198
|
end
|
|
199
199
|
end
|
|
200
200
|
end
|
data/lib/mneme/l2_runner.rb
CHANGED
|
@@ -110,22 +110,22 @@ module Mneme
|
|
|
110
110
|
# @return [Array<Hash>] single-element messages array
|
|
111
111
|
def build_messages(snapshots)
|
|
112
112
|
content = snapshots.map.with_index(1) { |snap, idx|
|
|
113
|
-
"--- Snapshot #{idx} (
|
|
113
|
+
"--- Snapshot #{idx} (messages #{snap.from_message_id}..#{snap.to_message_id}) ---\n#{snap.text}"
|
|
114
114
|
}.join("\n\n")
|
|
115
115
|
|
|
116
116
|
[{role: "user", content: "Compress these #{snapshots.size} Level 1 snapshots into a single Level 2 summary:\n\n#{content}"}]
|
|
117
117
|
end
|
|
118
118
|
|
|
119
119
|
# Builds the tool registry with L2 context for SaveSnapshot.
|
|
120
|
-
# The
|
|
120
|
+
# The message range spans from the first L1's start to the last L1's end.
|
|
121
121
|
#
|
|
122
122
|
# @param snapshots [Array<Snapshot>]
|
|
123
123
|
# @return [Tools::Registry]
|
|
124
124
|
def build_registry(snapshots)
|
|
125
125
|
registry = ::Tools::Registry.new(context: {
|
|
126
126
|
main_session: @session,
|
|
127
|
-
|
|
128
|
-
|
|
127
|
+
from_message_id: snapshots.first.from_message_id,
|
|
128
|
+
to_message_id: snapshots.last.to_message_id,
|
|
129
129
|
level: 2
|
|
130
130
|
})
|
|
131
131
|
TOOLS.each { |tool| registry.register(tool) }
|