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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +14 -8
  3. data/README.md +96 -23
  4. data/agents/codebase-analyzer.md +1 -1
  5. data/agents/codebase-pattern-finder.md +1 -1
  6. data/agents/documentation-researcher.md +1 -1
  7. data/agents/thoughts-analyzer.md +1 -1
  8. data/agents/web-search-researcher.md +2 -2
  9. data/app/channels/session_channel.rb +53 -35
  10. data/app/decorators/tool_call_decorator.rb +7 -7
  11. data/app/decorators/user_message_decorator.rb +3 -17
  12. data/app/jobs/agent_request_job.rb +15 -6
  13. data/app/jobs/passive_recall_job.rb +6 -11
  14. data/app/models/concerns/message/broadcasting.rb +1 -0
  15. data/app/models/goal.rb +14 -0
  16. data/app/models/message.rb +13 -31
  17. data/app/models/pending_message.rb +191 -0
  18. data/app/models/secret.rb +72 -0
  19. data/app/models/session.rb +480 -271
  20. data/bin/inspect-cassette +144 -0
  21. data/bin/release +212 -0
  22. data/bin/with-llms +20 -0
  23. data/config/database.yml +1 -0
  24. data/config/environments/test.rb +5 -0
  25. data/config/initializers/time_nanoseconds.rb +11 -0
  26. data/db/cable_structure.sql +9 -0
  27. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  28. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  29. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  30. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  31. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  32. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  33. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  34. data/db/queue_structure.sql +61 -0
  35. data/db/structure.sql +120 -0
  36. data/lib/agent_loop.rb +53 -51
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +19 -6
  39. data/lib/analytical_brain/tools/activate_skill.rb +2 -2
  40. data/lib/analytical_brain/tools/assign_nickname.rb +1 -1
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +2 -1
  42. data/lib/analytical_brain/tools/deactivate_workflow.rb +2 -1
  43. data/lib/analytical_brain/tools/finish_goal.rb +3 -0
  44. data/lib/analytical_brain/tools/goal_messaging.rb +28 -0
  45. data/lib/analytical_brain/tools/read_workflow.rb +2 -2
  46. data/lib/analytical_brain/tools/set_goal.rb +5 -1
  47. data/lib/analytical_brain/tools/update_goal.rb +5 -1
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/cli.rb +41 -13
  51. data/lib/anima/installer.rb +20 -1
  52. data/lib/anima/settings.rb +37 -2
  53. data/lib/anima/version.rb +1 -1
  54. data/lib/anima.rb +1 -1
  55. data/lib/credential_store.rb +17 -66
  56. data/lib/events/agent_message.rb +14 -0
  57. data/lib/events/base.rb +1 -1
  58. data/lib/events/subscribers/persister.rb +12 -18
  59. data/lib/events/subscribers/subagent_message_router.rb +18 -9
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +91 -50
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +9 -5
  65. data/lib/mneme/passive_recall.rb +85 -16
  66. data/lib/mneme/runner.rb +15 -4
  67. data/lib/providers/anthropic.rb +112 -7
  68. data/lib/shell_session.rb +239 -18
  69. data/lib/tools/base.rb +22 -0
  70. data/lib/tools/bash.rb +61 -7
  71. data/lib/tools/edit.rb +2 -2
  72. data/lib/tools/mark_goal_completed.rb +85 -0
  73. data/lib/tools/read.rb +2 -1
  74. data/lib/tools/recall.rb +98 -0
  75. data/lib/tools/registry.rb +41 -7
  76. data/lib/tools/remember.rb +1 -1
  77. data/lib/tools/response_truncator.rb +70 -0
  78. data/lib/tools/spawn_specialist.rb +11 -8
  79. data/lib/tools/spawn_subagent.rb +19 -13
  80. data/lib/tools/subagent_prompts.rb +41 -5
  81. data/lib/tools/think.rb +23 -0
  82. data/lib/tools/write.rb +1 -1
  83. data/lib/tui/app.rb +545 -137
  84. data/lib/tui/braille_spinner.rb +152 -0
  85. data/lib/tui/cable_client.rb +13 -20
  86. data/lib/tui/decorators/base_decorator.rb +40 -11
  87. data/lib/tui/decorators/bash_decorator.rb +3 -3
  88. data/lib/tui/decorators/edit_decorator.rb +7 -4
  89. data/lib/tui/decorators/read_decorator.rb +6 -8
  90. data/lib/tui/decorators/think_decorator.rb +4 -6
  91. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  92. data/lib/tui/decorators/write_decorator.rb +7 -4
  93. data/lib/tui/flash.rb +19 -14
  94. data/lib/tui/formatting.rb +33 -0
  95. data/lib/tui/input_buffer.rb +6 -6
  96. data/lib/tui/message_store.rb +159 -27
  97. data/lib/tui/performance_logger.rb +2 -3
  98. data/lib/tui/screens/chat.rb +302 -103
  99. data/lib/tui/settings.rb +86 -0
  100. data/skills/activerecord/SKILL.md +1 -1
  101. data/skills/dragonruby/SKILL.md +1 -1
  102. data/skills/draper-decorators/SKILL.md +1 -1
  103. data/skills/gh-issue.md +1 -1
  104. data/skills/mcp-server/SKILL.md +1 -1
  105. data/skills/ratatui-ruby/SKILL.md +1 -1
  106. data/skills/rspec/SKILL.md +1 -1
  107. data/templates/config.toml +30 -1
  108. data/templates/tui.toml +209 -0
  109. metadata +24 -3
  110. data/config/initializers/fts5_schema_dump.rb +0 -21
  111. 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. Supports both simple text chat
6
- # and multi-turn tool calling via the Anthropic tool use protocol.
5
+ # and handling tool execution loops.
7
6
  #
8
- # @example Simple chat (no tools)
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 message when a tool is skipped due to user interrupt.
19
- INTERRUPT_MESSAGE = "Stopped by user"
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
- # "Stopped by user" results and the loop exits without another LLM call.
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 [String, nil] the assistant's final text response, or nil when interrupted
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
- if interrupted?(session_id)
113
- clear_interrupt!(session_id)
114
- return nil
115
- end
105
+ messages.concat(promoted[:pairs])
106
+
107
+ return nil if handle_interrupt!(session_id)
116
108
  else
117
- return extract_text(response)
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
- if interrupted?(session_id)
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 "Stopped by user" results for all tools in the list.
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 "Stopped by user" result for a tool that was not
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} (interrupted)", tool_name: 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 database for a pending interrupt flag on the session.
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] whether the session has a pending interrupt request
257
- def interrupted?(session_id)
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
- # Clears the interrupt flag so the agent loop can continue with pending
262
- # messages. Also cleared by {AgentRequestJob#clear_interrupt} as a safety
263
- # net for unexpected exits.
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 clear
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 clear_interrupt!(session_id)
268
- Session.where(id: session_id).update_all(interrupt_requested: false)
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 Rails encrypted credentials are interpolated via
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
- # Rails encrypted credentials via {Mcp::Secrets}.
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 Rails encrypted credentials.
5
- # Secrets live under the +mcp+ namespace in the credentials file:
4
+ # CRUD operations for MCP server secrets stored in the encrypted secrets table.
5
+ # Secrets live under the +mcp+ namespace:
6
6
  #
7
- # mcp:
8
- # linear_api_key: "sk-xxx"
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 credentials.
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 credentials.
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 credentials.
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
- # Selects newest-first until budget exhausted, returns chronological.
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.deliverable
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: :desc).each do |message|
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.reverse
83
+ selected
80
84
  end
81
85
 
82
86
  # Splits messages into three zones by token count.
@@ -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 event history for related
6
- # context and caches the results on the session for viewport injection.
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
- # The agent never calls a tool; relevant memories appear automatically
9
- # in the viewport between snapshots and the sliding window. This mirrors
10
- # recognition memory in humanscontext surfaces without conscious effort.
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 event history using active goal descriptions as queries.
21
- # Returns recall results suitable for viewport injection.
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 [Array<Mneme::Search::Result>] deduplicated, relevance-sorted
28
+ # @return [Integer] number of pending messages created
24
29
  def call
25
30
  goals = @session.goals.active.root.includes(:sub_goals)
26
- return [] if goals.empty?
31
+ return 0 if goals.empty?
27
32
 
28
33
  search_terms = build_search_terms(goals)
29
- return [] if search_terms.blank?
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
- # Exclude events from the current session's viewport — no point recalling
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 after Mneme completes.
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 it to the last conversation message in the viewport, ensuring
157
- # the boundary is always a message/think message, never a tool_call/tool_response.
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
- new_boundary = viewport_messages.reverse_each.find { |message| conversation_or_think?(message) }
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