anima-core 1.2.0 → 1.4.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 +14 -8
- data/README.md +96 -23
- 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 +2 -2
- data/app/channels/session_channel.rb +53 -35
- data/app/decorators/tool_call_decorator.rb +7 -7
- data/app/decorators/user_message_decorator.rb +3 -17
- data/app/jobs/agent_request_job.rb +15 -6
- data/app/jobs/passive_recall_job.rb +6 -11
- data/app/models/concerns/message/broadcasting.rb +1 -0
- data/app/models/goal.rb +14 -0
- data/app/models/message.rb +13 -31
- data/app/models/pending_message.rb +191 -0
- data/app/models/secret.rb +72 -0
- data/app/models/session.rb +480 -271
- data/bin/inspect-cassette +144 -0
- data/bin/release +212 -0
- data/bin/with-llms +20 -0
- data/config/database.yml +1 -0
- data/config/environments/test.rb +5 -0
- data/config/initializers/time_nanoseconds.rb +11 -0
- data/db/cable_structure.sql +9 -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/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
- data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
- data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
- data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
- data/db/queue_structure.sql +61 -0
- data/db/structure.sql +120 -0
- data/lib/agent_loop.rb +53 -51
- data/lib/agents/definition.rb +1 -1
- data/lib/analytical_brain/runner.rb +19 -6
- data/lib/analytical_brain/tools/activate_skill.rb +2 -2
- data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
- data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
- data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
- data/lib/analytical_brain/tools/finish_goal.rb +3 -0
- data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
- data/lib/analytical_brain/tools/read_workflow.rb +2 -2
- data/lib/analytical_brain/tools/set_goal.rb +5 -1
- data/lib/analytical_brain/tools/update_goal.rb +5 -1
- data/lib/anima/cli/mcp/secrets.rb +4 -4
- data/lib/anima/cli/mcp.rb +4 -4
- data/lib/anima/cli.rb +41 -13
- data/lib/anima/installer.rb +20 -1
- data/lib/anima/settings.rb +37 -2
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +1 -1
- data/lib/credential_store.rb +17 -66
- data/lib/events/agent_message.rb +14 -0
- data/lib/events/base.rb +1 -1
- data/lib/events/subscribers/persister.rb +12 -18
- data/lib/events/subscribers/subagent_message_router.rb +18 -9
- data/lib/events/user_message.rb +2 -13
- data/lib/llm/client.rb +91 -50
- data/lib/mcp/config.rb +2 -2
- data/lib/mcp/secrets.rb +7 -8
- data/lib/mneme/compressed_viewport.rb +9 -5
- data/lib/mneme/passive_recall.rb +85 -16
- data/lib/mneme/runner.rb +15 -4
- data/lib/providers/anthropic.rb +112 -7
- data/lib/shell_session.rb +239 -18
- data/lib/tools/base.rb +22 -0
- data/lib/tools/bash.rb +61 -7
- data/lib/tools/edit.rb +2 -2
- data/lib/tools/mark_goal_completed.rb +85 -0
- data/lib/tools/read.rb +2 -1
- data/lib/tools/recall.rb +98 -0
- data/lib/tools/registry.rb +41 -7
- data/lib/tools/remember.rb +1 -1
- data/lib/tools/response_truncator.rb +70 -0
- data/lib/tools/spawn_specialist.rb +11 -8
- data/lib/tools/spawn_subagent.rb +19 -13
- data/lib/tools/subagent_prompts.rb +41 -5
- data/lib/tools/think.rb +23 -0
- data/lib/tools/write.rb +1 -1
- data/lib/tui/app.rb +545 -137
- data/lib/tui/braille_spinner.rb +152 -0
- data/lib/tui/cable_client.rb +13 -20
- data/lib/tui/decorators/base_decorator.rb +40 -11
- data/lib/tui/decorators/bash_decorator.rb +3 -3
- data/lib/tui/decorators/edit_decorator.rb +7 -4
- data/lib/tui/decorators/read_decorator.rb +6 -8
- data/lib/tui/decorators/think_decorator.rb +4 -6
- data/lib/tui/decorators/web_get_decorator.rb +4 -3
- data/lib/tui/decorators/write_decorator.rb +7 -4
- data/lib/tui/flash.rb +19 -14
- data/lib/tui/formatting.rb +33 -0
- data/lib/tui/input_buffer.rb +6 -6
- data/lib/tui/message_store.rb +159 -27
- data/lib/tui/performance_logger.rb +2 -3
- data/lib/tui/screens/chat.rb +302 -103
- data/lib/tui/settings.rb +86 -0
- 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 +30 -1
- data/templates/tui.toml +209 -0
- metadata +24 -3
- data/config/initializers/fts5_schema_dump.rb +0 -21
- data/lib/environment_probe.rb +0 -232
data/lib/llm/client.rb
CHANGED
|
@@ -2,21 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
module LLM
|
|
4
4
|
# Convenience layer over {Providers::Anthropic} for sending messages
|
|
5
|
-
# and handling tool execution loops.
|
|
6
|
-
# and multi-turn tool calling via the Anthropic tool use protocol.
|
|
5
|
+
# and handling tool execution loops.
|
|
7
6
|
#
|
|
8
|
-
# @example
|
|
9
|
-
# client = LLM::Client.new
|
|
10
|
-
# client.chat([{role: "user", content: "Say hello"}])
|
|
11
|
-
# # => "Hello! How can I help you today?"
|
|
12
|
-
#
|
|
13
|
-
# @example Chat with tools
|
|
7
|
+
# @example
|
|
14
8
|
# registry = Tools::Registry.new
|
|
15
9
|
# registry.register(Tools::WebGet)
|
|
16
10
|
# client.chat_with_tools(messages, registry: registry, session_id: session.id)
|
|
17
11
|
class Client
|
|
18
|
-
# Synthetic tool_result
|
|
19
|
-
INTERRUPT_MESSAGE = "
|
|
12
|
+
# Synthetic tool_result when a tool is skipped because the human pressed Escape.
|
|
13
|
+
INTERRUPT_MESSAGE = "Your human wants your attention"
|
|
20
14
|
|
|
21
15
|
# @return [Providers::Anthropic] the underlying API provider
|
|
22
16
|
attr_reader :provider
|
|
@@ -39,24 +33,6 @@ module LLM
|
|
|
39
33
|
@logger = logger
|
|
40
34
|
end
|
|
41
35
|
|
|
42
|
-
# Send messages to the LLM and return the assistant's text response.
|
|
43
|
-
#
|
|
44
|
-
# @param messages [Array<Hash>] conversation messages, each with +:role+ and +:content+
|
|
45
|
-
# @param options [Hash] additional API parameters (e.g. +system:+, +temperature:+)
|
|
46
|
-
# @return [String] the assistant's response text
|
|
47
|
-
# @raise [Providers::Anthropic::Error] on API errors
|
|
48
|
-
# @raise [Providers::Anthropic::AuthenticationError] on auth failures
|
|
49
|
-
def chat(messages, **options)
|
|
50
|
-
response = provider.create_message(
|
|
51
|
-
model: model,
|
|
52
|
-
messages: messages,
|
|
53
|
-
max_tokens: max_tokens,
|
|
54
|
-
**options
|
|
55
|
-
)
|
|
56
|
-
|
|
57
|
-
extract_text(response)
|
|
58
|
-
end
|
|
59
|
-
|
|
60
36
|
# Send messages with tool support. Runs the full tool execution loop:
|
|
61
37
|
# call LLM, execute any requested tools, feed results back, repeat
|
|
62
38
|
# until the LLM produces a final text response.
|
|
@@ -65,7 +41,7 @@ module LLM
|
|
|
65
41
|
# tool interaction so they're persisted and visible in the event stream.
|
|
66
42
|
#
|
|
67
43
|
# When the user interrupts via Escape, remaining tools receive synthetic
|
|
68
|
-
# "
|
|
44
|
+
# "Your human wants your attention" results and the loop exits without another LLM call.
|
|
69
45
|
#
|
|
70
46
|
# @param messages [Array<Hash>] conversation messages in Anthropic format
|
|
71
47
|
# @param registry [Tools::Registry] registered tools to make available
|
|
@@ -73,54 +49,89 @@ module LLM
|
|
|
73
49
|
# @param first_response [Hash, nil] pre-fetched first API response from
|
|
74
50
|
# {AgentLoop#deliver!}. Skips the first API call when provided so
|
|
75
51
|
# the Bounce Back transaction doesn't duplicate work.
|
|
52
|
+
# @param between_rounds [#call, nil] callback invoked after each tool
|
|
53
|
+
# round completes, before the next LLM request. Must return an
|
|
54
|
+
# +Array<String>+ of message contents to inject (e.g. promoted
|
|
55
|
+
# pending messages). Injected as +text+ blocks alongside
|
|
56
|
+
# +tool_result+ blocks so the LLM sees them in the next round.
|
|
76
57
|
# @param options [Hash] additional API parameters (e.g. +system:+)
|
|
77
|
-
# @return [
|
|
58
|
+
# @return [Hash, nil] +:text+ (String) and +:api_metrics+ (Hash), or nil when interrupted
|
|
78
59
|
# @raise [Providers::Anthropic::Error] on API errors
|
|
79
|
-
def chat_with_tools(messages, registry:, session_id:, first_response: nil, **options)
|
|
60
|
+
def chat_with_tools(messages, registry:, session_id:, first_response: nil, between_rounds: nil, **options)
|
|
80
61
|
messages = messages.dup
|
|
81
62
|
rounds = 0
|
|
63
|
+
last_api_metrics = nil
|
|
82
64
|
|
|
83
65
|
loop do
|
|
84
66
|
rounds += 1
|
|
85
67
|
max_rounds = Anima::Settings.max_tool_rounds
|
|
86
68
|
if rounds > max_rounds
|
|
87
|
-
return "[Tool loop exceeded #{max_rounds} rounds — halting]"
|
|
69
|
+
return {text: "[Tool loop exceeded #{max_rounds} rounds — halting]", api_metrics: last_api_metrics}
|
|
88
70
|
end
|
|
89
71
|
|
|
90
72
|
response = if first_response && rounds == 1
|
|
91
73
|
first_response
|
|
92
74
|
else
|
|
75
|
+
broadcast_session_state(session_id, "llm_generating")
|
|
93
76
|
provider.create_message(
|
|
94
77
|
model: model,
|
|
95
78
|
messages: messages,
|
|
96
79
|
max_tokens: max_tokens,
|
|
97
80
|
tools: registry.schemas,
|
|
81
|
+
include_metrics: true,
|
|
98
82
|
**options
|
|
99
83
|
)
|
|
100
84
|
end
|
|
101
85
|
|
|
86
|
+
# Capture api_metrics from ApiResponse wrapper (nil for pre-fetched first_response)
|
|
87
|
+
last_api_metrics = response.api_metrics if response.respond_to?(:api_metrics)
|
|
88
|
+
|
|
102
89
|
log(:debug, "stop_reason=#{response["stop_reason"]} content_types=#{(response["content"] || []).map { |b| b["type"] }.join(",")}")
|
|
103
90
|
|
|
104
91
|
if response["stop_reason"] == "tool_use"
|
|
105
92
|
tool_results = execute_tools(response, registry, session_id)
|
|
93
|
+
promoted = promote_between_rounds(between_rounds)
|
|
94
|
+
|
|
95
|
+
# Dual injection: user messages go as text blocks within the current
|
|
96
|
+
# tool_results turn (same speaker); sub-agent messages append as
|
|
97
|
+
# separate assistant→user turn pairs (distinct tool invocations).
|
|
98
|
+
promoted[:texts].each { |text| tool_results << {type: "text", text: text} }
|
|
106
99
|
|
|
107
100
|
messages += [
|
|
108
101
|
{role: "assistant", content: response["content"]},
|
|
109
102
|
{role: "user", content: tool_results}
|
|
110
103
|
]
|
|
111
104
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
end
|
|
105
|
+
messages.concat(promoted[:pairs])
|
|
106
|
+
|
|
107
|
+
return nil if handle_interrupt!(session_id)
|
|
116
108
|
else
|
|
117
|
-
|
|
109
|
+
# Discard the text response if the user pressed Escape while
|
|
110
|
+
# the API was generating it. Without this check the interrupt
|
|
111
|
+
# flag set during the blocking API call would be silently
|
|
112
|
+
# cleared by the ensure block in AgentRequestJob.
|
|
113
|
+
return nil if handle_interrupt!(session_id)
|
|
114
|
+
|
|
115
|
+
return {text: extract_text(response), api_metrics: last_api_metrics}
|
|
118
116
|
end
|
|
119
117
|
end
|
|
120
118
|
end
|
|
121
119
|
|
|
122
120
|
private
|
|
123
121
|
|
|
122
|
+
# Invokes the between_rounds callback and returns promoted messages
|
|
123
|
+
# split by injection strategy.
|
|
124
|
+
#
|
|
125
|
+
# @param between_rounds [#call, nil] callback returning
|
|
126
|
+
# +{texts: Array<String>, pairs: Array<Hash>}+
|
|
127
|
+
# @return [Hash{Symbol => Array}] +:texts+ for user messages (text blocks
|
|
128
|
+
# in current tool_results), +:pairs+ for sub-agent messages (separate
|
|
129
|
+
# conversation turns)
|
|
130
|
+
def promote_between_rounds(between_rounds)
|
|
131
|
+
return {texts: [], pairs: []} unless between_rounds
|
|
132
|
+
between_rounds.call
|
|
133
|
+
end
|
|
134
|
+
|
|
124
135
|
def build_provider(provider)
|
|
125
136
|
provider || Providers::Anthropic.new
|
|
126
137
|
end
|
|
@@ -151,9 +162,12 @@ module LLM
|
|
|
151
162
|
def execute_tools(response, registry, session_id)
|
|
152
163
|
tool_uses = extract_tool_uses(response)
|
|
153
164
|
results = []
|
|
165
|
+
interrupted = false
|
|
154
166
|
|
|
155
167
|
tool_uses.each_with_index do |tool_use, index|
|
|
156
|
-
|
|
168
|
+
# Check-only here; clearing happens in handle_interrupt! after the loop
|
|
169
|
+
interrupted ||= interrupt_requested?(session_id)
|
|
170
|
+
if interrupted
|
|
157
171
|
remaining = tool_uses[index..]
|
|
158
172
|
results.concat(interrupt_remaining_tools(remaining, session_id)) if remaining&.any?
|
|
159
173
|
break
|
|
@@ -164,7 +178,7 @@ module LLM
|
|
|
164
178
|
results
|
|
165
179
|
end
|
|
166
180
|
|
|
167
|
-
# Creates synthetic "
|
|
181
|
+
# Creates synthetic "Your human wants your attention" results for all tools in the list.
|
|
168
182
|
#
|
|
169
183
|
# @param tool_uses [Array<Hash>] remaining tool_use content blocks
|
|
170
184
|
# @param session_id [Integer, String] session ID for events
|
|
@@ -188,6 +202,8 @@ module LLM
|
|
|
188
202
|
|
|
189
203
|
log(:debug, "tool_call: #{name}(#{input.to_json})")
|
|
190
204
|
|
|
205
|
+
broadcast_session_state(session_id, "tool_executing", tool: name)
|
|
206
|
+
|
|
191
207
|
Events::Bus.emit(Events::ToolCall.new(
|
|
192
208
|
content: "Calling #{name}", tool_name: name,
|
|
193
209
|
tool_input: input, tool_use_id: id, timeout: timeout,
|
|
@@ -197,6 +213,7 @@ module LLM
|
|
|
197
213
|
result = registry.execute(name, input)
|
|
198
214
|
result = ToolDecorator.call(name, result)
|
|
199
215
|
result_content = format_tool_result(result)
|
|
216
|
+
result_content = truncate_tool_result(result_content, registry, name)
|
|
200
217
|
log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
|
|
201
218
|
|
|
202
219
|
Events::Bus.emit(Events::ToolResponse.new(
|
|
@@ -225,7 +242,7 @@ module LLM
|
|
|
225
242
|
{type: "tool_result", tool_use_id: id, content: error_content}
|
|
226
243
|
end
|
|
227
244
|
|
|
228
|
-
# Creates a synthetic "
|
|
245
|
+
# Creates a synthetic "Your human wants your attention" result for a tool that was not
|
|
229
246
|
# executed due to user interrupt. Emits both ToolCall and ToolResponse
|
|
230
247
|
# events so the TUI shows the interrupted tool in the event stream.
|
|
231
248
|
#
|
|
@@ -238,7 +255,7 @@ module LLM
|
|
|
238
255
|
input = tool_use["input"] || {}
|
|
239
256
|
|
|
240
257
|
Events::Bus.emit(Events::ToolCall.new(
|
|
241
|
-
content: "Skipped #{name}
|
|
258
|
+
content: "Skipped #{name} — your human wants your attention", tool_name: name,
|
|
242
259
|
tool_input: input, tool_use_id: id, session_id: session_id
|
|
243
260
|
))
|
|
244
261
|
|
|
@@ -250,22 +267,35 @@ module LLM
|
|
|
250
267
|
{type: "tool_result", tool_use_id: id, content: INTERRUPT_MESSAGE}
|
|
251
268
|
end
|
|
252
269
|
|
|
253
|
-
# Checks the
|
|
270
|
+
# Checks whether the session has a pending interrupt flag.
|
|
254
271
|
#
|
|
255
272
|
# @param session_id [Integer, String] session to check
|
|
256
|
-
# @return [Boolean]
|
|
257
|
-
def
|
|
273
|
+
# @return [Boolean] true when interrupt is pending
|
|
274
|
+
def interrupt_requested?(session_id)
|
|
258
275
|
Session.where(id: session_id, interrupt_requested: true).exists?
|
|
259
276
|
end
|
|
260
277
|
|
|
261
|
-
#
|
|
262
|
-
#
|
|
263
|
-
#
|
|
278
|
+
# Atomically checks for a pending interrupt and clears it in one query.
|
|
279
|
+
# Used at loop boundaries (after tools, before LLM text return) to
|
|
280
|
+
# short-circuit the agent loop when the user presses Escape.
|
|
281
|
+
#
|
|
282
|
+
# @param session_id [Integer, String] session to check
|
|
283
|
+
# @return [Boolean] true when interrupt was detected and cleared
|
|
284
|
+
def handle_interrupt!(session_id)
|
|
285
|
+
Session.where(id: session_id, interrupt_requested: true)
|
|
286
|
+
.update_all(interrupt_requested: false) > 0
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Broadcasts a session state transition to all subscribed clients.
|
|
290
|
+
# Delegates to {Session#broadcast_session_state} which handles both
|
|
291
|
+
# the session's own stream and the parent's stream for HUD updates.
|
|
264
292
|
#
|
|
265
|
-
# @param session_id [Integer, String] session to
|
|
293
|
+
# @param session_id [Integer, String] session to broadcast for
|
|
294
|
+
# @param state [String] one of "idle", "llm_generating", "tool_executing", "interrupting"
|
|
295
|
+
# @param tool [String, nil] tool name when state is "tool_executing"
|
|
266
296
|
# @return [void]
|
|
267
|
-
def
|
|
268
|
-
Session.
|
|
297
|
+
def broadcast_session_state(session_id, state, tool: nil)
|
|
298
|
+
Session.find_by(id: session_id)&.broadcast_session_state(state, tool: tool)
|
|
269
299
|
end
|
|
270
300
|
|
|
271
301
|
def log(level, message)
|
|
@@ -277,5 +307,16 @@ module LLM
|
|
|
277
307
|
def format_tool_result(result)
|
|
278
308
|
result.is_a?(Hash) ? result.to_json : result.to_s
|
|
279
309
|
end
|
|
310
|
+
|
|
311
|
+
# Applies head+tail truncation when a tool result exceeds the tool's
|
|
312
|
+
# configured character threshold. Skips tools that opt out (e.g. read).
|
|
313
|
+
def truncate_tool_result(content, registry, tool_name)
|
|
314
|
+
threshold = registry.truncation_threshold(tool_name)
|
|
315
|
+
return content unless threshold
|
|
316
|
+
|
|
317
|
+
lines = Tools::ResponseTruncator::HEAD_LINES
|
|
318
|
+
reason = "#{tool_name} output displays first/last #{lines} lines"
|
|
319
|
+
Tools::ResponseTruncator.truncate(content, threshold: threshold, reason: reason)
|
|
320
|
+
end
|
|
280
321
|
end
|
|
281
322
|
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]
|
|
@@ -52,12 +52,16 @@ module Mneme
|
|
|
52
52
|
private
|
|
53
53
|
|
|
54
54
|
# Fetches messages within token budget, starting from from_message_id.
|
|
55
|
-
#
|
|
55
|
+
# Walks oldest-first from the boundary so Mneme processes the eviction
|
|
56
|
+
# zone (oldest messages) rather than the recent zone. This ensures
|
|
57
|
+
# {Mneme::Runner#advance_boundary} advances past only the oldest third,
|
|
58
|
+
# preserving recent conversation context in the main viewport.
|
|
59
|
+
#
|
|
56
60
|
# Caches per-message token costs in @message_costs for reuse by split_into_zones.
|
|
57
61
|
#
|
|
58
|
-
# @return [Array<Message>]
|
|
62
|
+
# @return [Array<Message>] chronologically ordered (oldest first)
|
|
59
63
|
def fetch_messages
|
|
60
|
-
scope = @session.messages.context_messages
|
|
64
|
+
scope = @session.messages.context_messages
|
|
61
65
|
|
|
62
66
|
if @from_message_id
|
|
63
67
|
scope = scope.where("id >= ?", @from_message_id)
|
|
@@ -67,7 +71,7 @@ module Mneme
|
|
|
67
71
|
@message_costs = {}
|
|
68
72
|
remaining = @token_budget
|
|
69
73
|
|
|
70
|
-
scope.reorder(id: :
|
|
74
|
+
scope.reorder(id: :asc).each do |message|
|
|
71
75
|
cost = message_token_cost(message)
|
|
72
76
|
break if cost > remaining && selected.any?
|
|
73
77
|
|
|
@@ -76,7 +80,7 @@ module Mneme
|
|
|
76
80
|
remaining -= cost
|
|
77
81
|
end
|
|
78
82
|
|
|
79
|
-
selected
|
|
83
|
+
selected
|
|
80
84
|
end
|
|
81
85
|
|
|
82
86
|
# Splits messages into three zones by token count.
|
data/lib/mneme/passive_recall.rb
CHANGED
|
@@ -2,38 +2,41 @@
|
|
|
2
2
|
|
|
3
3
|
module Mneme
|
|
4
4
|
# Passive recall — automatic memory surfacing triggered by Goal updates.
|
|
5
|
-
# When goals are created or updated, searches
|
|
6
|
-
# context and
|
|
5
|
+
# When goals are created or updated, searches message history for related
|
|
6
|
+
# context and enqueues phantom tool_call/tool_response pairs via the
|
|
7
|
+
# PendingMessage pipeline.
|
|
7
8
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
9
|
+
# Phantom pairs are promoted into real Message records by
|
|
10
|
+
# {Session#promote_pending_messages!} between agent loop rounds, then
|
|
11
|
+
# ride the conveyor belt like regular messages — cached as part of the
|
|
12
|
+
# stable prefix, compressed by Mneme on eviction.
|
|
11
13
|
#
|
|
12
14
|
# @example Trigger after a goal update
|
|
13
15
|
# Mneme::PassiveRecall.new(session).call
|
|
14
16
|
class PassiveRecall
|
|
17
|
+
# Estimated token overhead for a tool_use wrapper (name + input fields).
|
|
18
|
+
TOOL_PAIR_OVERHEAD_TOKENS = 50
|
|
19
|
+
|
|
15
20
|
# @param session [Session] the session whose goals drive recall
|
|
16
21
|
def initialize(session)
|
|
17
22
|
@session = session
|
|
18
23
|
end
|
|
19
24
|
|
|
20
|
-
# Searches
|
|
21
|
-
#
|
|
25
|
+
# Searches message history using active goal descriptions as queries.
|
|
26
|
+
# Enqueues phantom recall pairs for new results not already recalled.
|
|
22
27
|
#
|
|
23
|
-
# @return [
|
|
28
|
+
# @return [Integer] number of pending messages created
|
|
24
29
|
def call
|
|
25
30
|
goals = @session.goals.active.root.includes(:sub_goals)
|
|
26
|
-
return
|
|
31
|
+
return 0 if goals.empty?
|
|
27
32
|
|
|
28
33
|
search_terms = build_search_terms(goals)
|
|
29
|
-
return
|
|
34
|
+
return 0 if search_terms.blank?
|
|
30
35
|
|
|
31
36
|
results = Mneme::Search.query(search_terms, limit: Anima::Settings.recall_max_results)
|
|
37
|
+
results = filter_duplicates(results)
|
|
32
38
|
|
|
33
|
-
|
|
34
|
-
# what the agent already sees.
|
|
35
|
-
viewport_ids = @session.viewport_message_ids.to_set
|
|
36
|
-
results.reject { |result| viewport_ids.include?(result.message_id) }
|
|
39
|
+
enqueue_pending_messages(results)
|
|
37
40
|
end
|
|
38
41
|
|
|
39
42
|
private
|
|
@@ -46,8 +49,6 @@ module Mneme
|
|
|
46
49
|
]).freeze
|
|
47
50
|
|
|
48
51
|
# Extracts meaningful keywords from active goals and joins with OR.
|
|
49
|
-
# Stop words and generic verbs are stripped — they're too common to
|
|
50
|
-
# produce useful recall results.
|
|
51
52
|
#
|
|
52
53
|
# @param goals [ActiveRecord::Relation<Goal>]
|
|
53
54
|
# @return [String] FTS5 OR-joined keywords
|
|
@@ -65,5 +66,73 @@ module Mneme
|
|
|
65
66
|
|
|
66
67
|
words.join(" OR ").truncate(500)
|
|
67
68
|
end
|
|
69
|
+
|
|
70
|
+
# Excludes results already in the viewport or already recalled (pending or promoted).
|
|
71
|
+
#
|
|
72
|
+
# @param results [Array<Mneme::Search::Result>]
|
|
73
|
+
# @return [Array<Mneme::Search::Result>]
|
|
74
|
+
def filter_duplicates(results)
|
|
75
|
+
viewport_ids = @session.viewport_message_ids.to_set
|
|
76
|
+
|
|
77
|
+
existing_recall_ids = @session.messages
|
|
78
|
+
.where(message_type: "tool_call")
|
|
79
|
+
.where("payload ->> 'tool_name' = ?", PendingMessage::RECALL_MEMORY_TOOL)
|
|
80
|
+
.pluck(:tool_use_id)
|
|
81
|
+
.to_set
|
|
82
|
+
|
|
83
|
+
pending_recall_ids = @session.pending_messages
|
|
84
|
+
.where(source_type: "recall")
|
|
85
|
+
.pluck(:source_name)
|
|
86
|
+
.map { |name| "recall_#{name}" }
|
|
87
|
+
.to_set
|
|
88
|
+
|
|
89
|
+
known_ids = existing_recall_ids | pending_recall_ids
|
|
90
|
+
|
|
91
|
+
results.reject { |result|
|
|
92
|
+
viewport_ids.include?(result.message_id) ||
|
|
93
|
+
known_ids.include?("recall_#{result.message_id}")
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Creates PendingMessages for each recall result.
|
|
98
|
+
#
|
|
99
|
+
# @param results [Array<Mneme::Search::Result>]
|
|
100
|
+
# @return [Integer] number of pending messages created
|
|
101
|
+
def enqueue_pending_messages(results)
|
|
102
|
+
messages_by_id = Message.where(id: results.map(&:message_id))
|
|
103
|
+
.includes(:session).index_by(&:id)
|
|
104
|
+
|
|
105
|
+
count = 0
|
|
106
|
+
remaining = (Anima::Settings.token_budget * Anima::Settings.recall_budget_fraction).to_i
|
|
107
|
+
|
|
108
|
+
results.each do |result|
|
|
109
|
+
snippet = format_snippet(result, messages_by_id)
|
|
110
|
+
cost = Message.estimate_token_count(snippet.bytesize) + TOOL_PAIR_OVERHEAD_TOKENS
|
|
111
|
+
break if cost > remaining && count > 0
|
|
112
|
+
|
|
113
|
+
@session.pending_messages.create!(
|
|
114
|
+
content: snippet,
|
|
115
|
+
source_type: "recall",
|
|
116
|
+
source_name: result.message_id.to_s
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
remaining -= cost
|
|
120
|
+
count += 1
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
count
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Formats a search result as a compact snippet.
|
|
127
|
+
#
|
|
128
|
+
# @param result [Mneme::Search::Result]
|
|
129
|
+
# @param messages_by_id [Hash{Integer => Message}] pre-fetched messages
|
|
130
|
+
# @return [String]
|
|
131
|
+
def format_snippet(result, messages_by_id)
|
|
132
|
+
msg = messages_by_id[result.message_id]
|
|
133
|
+
session_label = msg&.session&.name || "session ##{result.session_id}"
|
|
134
|
+
content = result.snippet.truncate(Anima::Settings.recall_max_snippet_tokens * Message::BYTES_PER_TOKEN)
|
|
135
|
+
"message #{result.message_id} (#{session_label}): #{content}"
|
|
136
|
+
end
|
|
68
137
|
end
|
|
69
138
|
end
|
data/lib/mneme/runner.rb
CHANGED
|
@@ -148,13 +148,15 @@ module Mneme
|
|
|
148
148
|
registry
|
|
149
149
|
end
|
|
150
150
|
|
|
151
|
-
# Advances the terminal message pointer
|
|
151
|
+
# Advances the terminal message pointer past the zone Mneme just processed.
|
|
152
152
|
# Runs unconditionally — even when the LLM called `everything_ok` (no snapshot
|
|
153
153
|
# needed), the zone was reviewed and should be advanced past. Without this,
|
|
154
154
|
# Mneme would re-examine the same mechanical-only content on every trigger.
|
|
155
155
|
#
|
|
156
|
-
# Sets
|
|
157
|
-
# the
|
|
156
|
+
# Sets the boundary to the first conversation/think message AFTER Mneme's
|
|
157
|
+
# viewport — the start of the remaining context. This creates the batch
|
|
158
|
+
# eviction cycle: the next Mneme trigger fires only after this boundary
|
|
159
|
+
# message itself falls out of the main viewport (~1/3 turnover later).
|
|
158
160
|
# Also updates the snapshot range pointers.
|
|
159
161
|
#
|
|
160
162
|
# @param viewport [Mneme::CompressedViewport]
|
|
@@ -162,7 +164,16 @@ module Mneme
|
|
|
162
164
|
viewport_messages = viewport.messages
|
|
163
165
|
return if viewport_messages.empty?
|
|
164
166
|
|
|
165
|
-
|
|
167
|
+
last_processed_id = viewport_messages.last.id
|
|
168
|
+
new_boundary = @session.messages
|
|
169
|
+
.where("id > ?", last_processed_id)
|
|
170
|
+
.where(message_type: Message::CONVERSATION_TYPES + ["tool_call"])
|
|
171
|
+
.order(:id)
|
|
172
|
+
.find_each { |msg| break msg if conversation_or_think?(msg) }
|
|
173
|
+
|
|
174
|
+
# Fall back to the last message in Mneme's viewport when no conversation
|
|
175
|
+
# messages exist beyond it (e.g. session went quiet after the zone).
|
|
176
|
+
new_boundary ||= viewport_messages.reverse_each.find { |msg| conversation_or_think?(msg) }
|
|
166
177
|
return unless new_boundary
|
|
167
178
|
|
|
168
179
|
boundary_id = new_boundary.id
|