anima-core 1.0.1 → 1.1.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +61 -0
  4. data/README.md +202 -116
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +44 -10
  7. data/app/decorators/agent_message_decorator.rb +6 -0
  8. data/app/decorators/event_decorator.rb +41 -7
  9. data/app/decorators/tool_call_decorator.rb +66 -5
  10. data/app/decorators/tool_decorator.rb +57 -0
  11. data/app/decorators/tool_response_decorator.rb +35 -5
  12. data/app/decorators/user_message_decorator.rb +6 -0
  13. data/app/decorators/web_get_tool_decorator.rb +102 -0
  14. data/app/jobs/agent_request_job.rb +95 -20
  15. data/app/jobs/mneme_job.rb +51 -0
  16. data/app/jobs/passive_recall_job.rb +29 -0
  17. data/app/models/concerns/event/broadcasting.rb +18 -0
  18. data/app/models/event.rb +10 -0
  19. data/app/models/goal.rb +27 -0
  20. data/app/models/goal_pinned_event.rb +11 -0
  21. data/app/models/pinned_event.rb +41 -0
  22. data/app/models/session.rb +335 -6
  23. data/app/models/snapshot.rb +76 -0
  24. data/config/initializers/event_subscribers.rb +14 -3
  25. data/config/initializers/fts5_schema_dump.rb +21 -0
  26. data/db/migrate/20260316094817_add_interrupt_requested_to_sessions.rb +5 -0
  27. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  28. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  29. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  30. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  31. data/lib/agent_loop.rb +67 -18
  32. data/lib/analytical_brain/runner.rb +159 -84
  33. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  34. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  35. data/lib/anima/cli.rb +34 -1
  36. data/lib/anima/config_migrator.rb +205 -0
  37. data/lib/anima/installer.rb +13 -130
  38. data/lib/anima/settings.rb +42 -1
  39. data/lib/anima/version.rb +1 -1
  40. data/lib/events/bounce_back.rb +37 -0
  41. data/lib/events/subscribers/agent_dispatcher.rb +29 -0
  42. data/lib/events/subscribers/persister.rb +17 -0
  43. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  44. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  45. data/lib/llm/client.rb +99 -14
  46. data/lib/mneme/compressed_viewport.rb +200 -0
  47. data/lib/mneme/l2_runner.rb +138 -0
  48. data/lib/mneme/passive_recall.rb +69 -0
  49. data/lib/mneme/runner.rb +254 -0
  50. data/lib/mneme/search.rb +150 -0
  51. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  52. data/lib/mneme/tools/everything_ok.rb +24 -0
  53. data/lib/mneme/tools/save_snapshot.rb +68 -0
  54. data/lib/mneme.rb +29 -0
  55. data/lib/providers/anthropic.rb +57 -13
  56. data/lib/shell_session.rb +188 -59
  57. data/lib/tasks/fts5.rake +6 -0
  58. data/lib/tools/remember.rb +179 -0
  59. data/lib/tools/spawn_specialist.rb +21 -9
  60. data/lib/tools/spawn_subagent.rb +22 -11
  61. data/lib/tools/subagent_prompts.rb +20 -3
  62. data/lib/tools/think.rb +57 -0
  63. data/lib/tools/web_get.rb +15 -6
  64. data/lib/tui/app.rb +230 -127
  65. data/lib/tui/cable_client.rb +8 -0
  66. data/lib/tui/decorators/base_decorator.rb +165 -0
  67. data/lib/tui/decorators/bash_decorator.rb +20 -0
  68. data/lib/tui/decorators/edit_decorator.rb +19 -0
  69. data/lib/tui/decorators/read_decorator.rb +24 -0
  70. data/lib/tui/decorators/think_decorator.rb +36 -0
  71. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  72. data/lib/tui/decorators/write_decorator.rb +19 -0
  73. data/lib/tui/flash.rb +139 -0
  74. data/lib/tui/formatting.rb +28 -0
  75. data/lib/tui/height_map.rb +93 -0
  76. data/lib/tui/message_store.rb +25 -1
  77. data/lib/tui/performance_logger.rb +90 -0
  78. data/lib/tui/screens/chat.rb +374 -109
  79. data/templates/config.toml +156 -0
  80. metadata +87 -4
  81. data/CHANGELOG.md +0 -79
  82. data/Gemfile +0 -17
  83. data/lib/tools/return_result.rb +0 -81
data/lib/llm/client.rb CHANGED
@@ -15,6 +15,9 @@ 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 message when a tool is skipped due to user interrupt.
19
+ INTERRUPT_MESSAGE = "Stopped by user"
20
+
18
21
  # @return [Providers::Anthropic] the underlying API provider
19
22
  attr_reader :provider
20
23
 
@@ -61,13 +64,19 @@ module LLM
61
64
  # Emits {Events::ToolCall} and {Events::ToolResponse} events for each
62
65
  # tool interaction so they're persisted and visible in the event stream.
63
66
  #
67
+ # When the user interrupts via Escape, remaining tools receive synthetic
68
+ # "Stopped by user" results and the loop exits without another LLM call.
69
+ #
64
70
  # @param messages [Array<Hash>] conversation messages in Anthropic format
65
71
  # @param registry [Tools::Registry] registered tools to make available
66
72
  # @param session_id [Integer, String] session ID for emitted events
73
+ # @param first_response [Hash, nil] pre-fetched first API response from
74
+ # {AgentLoop#deliver!}. Skips the first API call when provided so
75
+ # the Bounce Back transaction doesn't duplicate work.
67
76
  # @param options [Hash] additional API parameters (e.g. +system:+)
68
- # @return [String] the assistant's final text response
77
+ # @return [String, nil] the assistant's final text response, or nil when interrupted
69
78
  # @raise [Providers::Anthropic::Error] on API errors
70
- def chat_with_tools(messages, registry:, session_id:, **options)
79
+ def chat_with_tools(messages, registry:, session_id:, first_response: nil, **options)
71
80
  messages = messages.dup
72
81
  rounds = 0
73
82
 
@@ -78,13 +87,17 @@ module LLM
78
87
  return "[Tool loop exceeded #{max_rounds} rounds — halting]"
79
88
  end
80
89
 
81
- response = provider.create_message(
82
- model: model,
83
- messages: messages,
84
- max_tokens: max_tokens,
85
- tools: registry.schemas,
86
- **options
87
- )
90
+ response = if first_response && rounds == 1
91
+ first_response
92
+ else
93
+ provider.create_message(
94
+ model: model,
95
+ messages: messages,
96
+ max_tokens: max_tokens,
97
+ tools: registry.schemas,
98
+ **options
99
+ )
100
+ end
88
101
 
89
102
  log(:debug, "stop_reason=#{response["stop_reason"]} content_types=#{(response["content"] || []).map { |b| b["type"] }.join(",")}")
90
103
 
@@ -95,6 +108,11 @@ module LLM
95
108
  {role: "assistant", content: response["content"]},
96
109
  {role: "user", content: tool_results}
97
110
  ]
111
+
112
+ if interrupted?(session_id)
113
+ clear_interrupt!(session_id)
114
+ return nil
115
+ end
98
116
  else
99
117
  return extract_text(response)
100
118
  end
@@ -122,20 +140,43 @@ module LLM
122
140
  end
123
141
 
124
142
  # Executes all tool_use blocks from a response, emitting events for each.
143
+ # Checks for user interrupt between tools — remaining tools receive
144
+ # synthetic results to satisfy the Anthropic API's tool_use/tool_result
145
+ # pairing requirement (a missing result permanently breaks the conversation).
125
146
  #
126
147
  # @param response [Hash] Anthropic API response with tool_use content blocks
127
148
  # @param registry [Tools::Registry] tool registry for dispatch
128
149
  # @param session_id [Integer, String] session ID for events
129
150
  # @return [Array<Hash>] tool_result content blocks for the next API call
130
151
  def execute_tools(response, registry, session_id)
131
- extract_tool_uses(response).map do |tool_use|
132
- execute_single_tool(tool_use, registry, session_id)
152
+ tool_uses = extract_tool_uses(response)
153
+ results = []
154
+
155
+ tool_uses.each_with_index do |tool_use, index|
156
+ if interrupted?(session_id)
157
+ remaining = tool_uses[index..]
158
+ results.concat(interrupt_remaining_tools(remaining, session_id)) if remaining&.any?
159
+ break
160
+ end
161
+ results << execute_single_tool(tool_use, registry, session_id)
133
162
  end
163
+
164
+ results
134
165
  end
135
166
 
136
- # Executes a single tool and always returns a tool_result even if
137
- # the tool raises. The LLM requires every tool_use to have a matching
138
- # tool_result; a missing result breaks the conversation permanently.
167
+ # Creates synthetic "Stopped by user" results for all tools in the list.
168
+ #
169
+ # @param tool_uses [Array<Hash>] remaining tool_use content blocks
170
+ # @param session_id [Integer, String] session ID for events
171
+ # @return [Array<Hash>] tool_result content blocks
172
+ def interrupt_remaining_tools(tool_uses, session_id)
173
+ tool_uses.map { |tool_use| interrupt_tool(tool_use, session_id) }
174
+ end
175
+
176
+ # Executes a single tool and always returns a tool_result — even if the
177
+ # tool raises. Per the Anthropic tool-use protocol, every tool_use must
178
+ # have a matching tool_result; a missing result permanently corrupts the
179
+ # conversation history and breaks the session.
139
180
  def execute_single_tool(tool_use, registry, session_id)
140
181
  name = tool_use["name"]
141
182
  id = tool_use["id"]
@@ -155,6 +196,7 @@ module LLM
155
196
  {error: "#{error.class}: #{error.message}"}
156
197
  end
157
198
 
199
+ result = ToolDecorator.call(name, result)
158
200
  result_content = format_tool_result(result)
159
201
  log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
160
202
 
@@ -167,6 +209,49 @@ module LLM
167
209
  {type: "tool_result", tool_use_id: id, content: result_content}
168
210
  end
169
211
 
212
+ # Creates a synthetic "Stopped by user" result for a tool that was not
213
+ # executed due to user interrupt. Emits both ToolCall and ToolResponse
214
+ # events so the TUI shows the interrupted tool in the event stream.
215
+ #
216
+ # @param tool_use [Hash] Anthropic tool_use content block
217
+ # @param session_id [Integer, String] session ID for events
218
+ # @return [Hash] tool_result content block
219
+ def interrupt_tool(tool_use, session_id)
220
+ name = tool_use["name"]
221
+ id = tool_use["id"]
222
+ input = tool_use["input"] || {}
223
+
224
+ Events::Bus.emit(Events::ToolCall.new(
225
+ content: "Skipped #{name} (interrupted)", tool_name: name,
226
+ tool_input: input, tool_use_id: id, session_id: session_id
227
+ ))
228
+
229
+ Events::Bus.emit(Events::ToolResponse.new(
230
+ content: INTERRUPT_MESSAGE, tool_name: name, tool_use_id: id,
231
+ success: false, session_id: session_id
232
+ ))
233
+
234
+ {type: "tool_result", tool_use_id: id, content: INTERRUPT_MESSAGE}
235
+ end
236
+
237
+ # Checks the database for a pending interrupt flag on the session.
238
+ #
239
+ # @param session_id [Integer, String] session to check
240
+ # @return [Boolean] whether the session has a pending interrupt request
241
+ def interrupted?(session_id)
242
+ Session.where(id: session_id, interrupt_requested: true).exists?
243
+ end
244
+
245
+ # Clears the interrupt flag so the agent loop can continue with pending
246
+ # messages. Also cleared by {AgentRequestJob#clear_interrupt} as a safety
247
+ # net for unexpected exits.
248
+ #
249
+ # @param session_id [Integer, String] session to clear
250
+ # @return [void]
251
+ def clear_interrupt!(session_id)
252
+ Session.where(id: session_id).update_all(interrupt_requested: false)
253
+ end
254
+
170
255
  def log(level, message)
171
256
  return unless @logger
172
257
 
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ # Builds a compressed viewport for Mneme's LLM context. Mneme sees
5
+ # conversation (user/agent messages and think events) but not mechanical
6
+ # execution (tool calls and responses). Tool calls are compressed to
7
+ # aggregate counters like `[4 tools called]`.
8
+ #
9
+ # The viewport is split into three zones separated by delimiters:
10
+ # - **Eviction zone** — events about to leave the viewport (upper third)
11
+ # - **Middle zone** — events in the middle of the viewport
12
+ # - **Recent zone** — the most recent events (lower third)
13
+ #
14
+ # Zone boundaries are calculated WITH tool call tokens (they affect
15
+ # position), then tool calls are removed and replaced with counters.
16
+ #
17
+ # @example
18
+ # viewport = Mneme::CompressedViewport.new(session, token_budget: 60_000)
19
+ # viewport.render #=> "── EVICTION ZONE ──\nevent 42 User: ..."
20
+ class CompressedViewport
21
+ ZONE_DELIMITERS = {
22
+ eviction: "── EVICTION ZONE (upper third) ──",
23
+ middle: "── MIDDLE ZONE ──",
24
+ recent: "── RECENT ZONE (lower third) ──"
25
+ }.freeze
26
+
27
+ # @param session [Session] the session to build viewport for
28
+ # @param token_budget [Integer] total tokens available for Mneme's viewport
29
+ # @param from_event_id [Integer, nil] start from this event ID (inclusive);
30
+ # when nil, uses the session's full viewport
31
+ def initialize(session, token_budget:, from_event_id: nil)
32
+ @session = session
33
+ @token_budget = token_budget
34
+ @from_event_id = from_event_id
35
+ end
36
+
37
+ # Renders the compressed viewport as a string ready for Mneme's LLM context.
38
+ #
39
+ # @return [String] compressed viewport with zone delimiters
40
+ def render
41
+ return "" if events.empty?
42
+
43
+ zones = split_into_zones(events)
44
+ render_zones(zones)
45
+ end
46
+
47
+ # @return [Array<Event>] the raw events selected for this viewport
48
+ def events
49
+ @events ||= fetch_events
50
+ end
51
+
52
+ private
53
+
54
+ # Fetches events within token budget, starting from from_event_id.
55
+ # Selects newest-first until budget exhausted, returns chronological.
56
+ # Caches per-event token costs in @event_costs for reuse by split_into_zones.
57
+ #
58
+ # @return [Array<Event>]
59
+ def fetch_events
60
+ scope = @session.events.context_events.deliverable
61
+
62
+ if @from_event_id
63
+ scope = scope.where("id >= ?", @from_event_id)
64
+ end
65
+
66
+ selected = []
67
+ @event_costs = {}
68
+ remaining = @token_budget
69
+
70
+ scope.reorder(id: :desc).each do |event|
71
+ cost = event_token_cost(event)
72
+ break if cost > remaining && selected.any?
73
+
74
+ selected << event
75
+ @event_costs[event.id] = cost
76
+ remaining -= cost
77
+ end
78
+
79
+ selected.reverse
80
+ end
81
+
82
+ # Splits events into three zones by token count.
83
+ # Zone boundaries are calculated including ALL events (tool calls count
84
+ # toward position), but zone assignment uses cumulative tokens.
85
+ #
86
+ # @return [Hash{Symbol => Array<Event>}] :eviction, :middle, :recent
87
+ def split_into_zones(events)
88
+ costs = events.map { |event| [event, @event_costs[event.id] || event_token_cost(event)] }
89
+ zone_size = costs.sum(&:last) / 3.0
90
+
91
+ result = {eviction: [], middle: [], recent: []}
92
+ cumulative = 0
93
+
94
+ costs.each do |event, cost|
95
+ cumulative += cost
96
+ result[zone_for_cumulative(cumulative, zone_size)] << event
97
+ end
98
+
99
+ result
100
+ end
101
+
102
+ # Renders zones with delimiters, compressing tool calls into counters.
103
+ #
104
+ # @param zones [Hash{Symbol => Array<Event>}]
105
+ # @return [String]
106
+ def render_zones(zones)
107
+ %i[eviction middle recent].flat_map { |name|
108
+ [ZONE_DELIMITERS[name], render_zone(zones[name])]
109
+ }.join("\n")
110
+ end
111
+
112
+ # Determines which zone an event belongs to based on cumulative token position.
113
+ #
114
+ # @param cumulative [Numeric] cumulative token count including this event
115
+ # @param zone_size [Float] token count per zone (total / 3)
116
+ # @return [Symbol] :eviction, :middle, or :recent
117
+ def zone_for_cumulative(cumulative, zone_size)
118
+ if cumulative <= zone_size
119
+ :eviction
120
+ elsif cumulative <= zone_size * 2
121
+ :middle
122
+ else
123
+ :recent
124
+ end
125
+ end
126
+
127
+ # Renders a single zone: conversation events as full text, consecutive
128
+ # tool calls/responses compressed into `[N tools called]` counters.
129
+ # tool_response events are intentionally silent — they affect zone boundaries
130
+ # via token cost but are not rendered; only tool_call events increment the counter.
131
+ #
132
+ # @param zone_events [Array<Event>]
133
+ # @return [String]
134
+ def render_zone(zone_events)
135
+ lines = []
136
+ tool_count = 0
137
+
138
+ zone_events.each do |event|
139
+ if conversation_event?(event) || think_event?(event)
140
+ lines << flush_tool_count(tool_count)
141
+ tool_count = 0
142
+ lines << render_event_line(event)
143
+ elsif event.event_type == "tool_call"
144
+ tool_count += 1
145
+ end
146
+ end
147
+
148
+ lines << flush_tool_count(tool_count)
149
+ lines.compact.join("\n")
150
+ end
151
+
152
+ # @return [Boolean] true if event is a user/agent/system message
153
+ def conversation_event?(event)
154
+ event.event_type.in?(Event::CONVERSATION_TYPES)
155
+ end
156
+
157
+ # Think events are tool_call events with tool_name == "think".
158
+ # They carry the agent's reasoning and are treated as conversation.
159
+ #
160
+ # @return [Boolean]
161
+ def think_event?(event)
162
+ event.event_type == "tool_call" && event.payload["tool_name"] == Event::THINK_TOOL
163
+ end
164
+
165
+ ROLE_LABELS = {
166
+ "user_message" => "User",
167
+ "agent_message" => "Assistant",
168
+ "system_message" => "System"
169
+ }.freeze
170
+
171
+ # Renders a single event as a transcript line.
172
+ #
173
+ # @param event [Event]
174
+ # @return [String]
175
+ def render_event_line(event)
176
+ prefix = "event #{event.id}"
177
+ data = event.payload
178
+ if think_event?(event)
179
+ "#{prefix} Think: #{data.dig("tool_input", "thoughts")}"
180
+ else
181
+ "#{prefix} #{ROLE_LABELS.fetch(event.event_type)}: #{data["content"]}"
182
+ end
183
+ end
184
+
185
+ # Returns a tool count string if any tools were called, nil otherwise.
186
+ #
187
+ # @param count [Integer] number of tool calls to flush
188
+ # @return [String, nil]
189
+ def flush_tool_count(count)
190
+ return if count == 0
191
+ "[#{count} #{(count == 1) ? "tool" : "tools"} called]"
192
+ end
193
+
194
+ # @return [Integer] token cost using cached count or heuristic
195
+ def event_token_cost(event)
196
+ cached = event.token_count
197
+ (cached > 0) ? cached : event.estimate_tokens
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ # Compresses multiple Level 1 snapshots into a single Level 2 snapshot.
5
+ # L2 snapshots capture days/weeks-scale context from hourly L1 summaries,
6
+ # preventing unbounded snapshot growth via recursive compression.
7
+ #
8
+ # Triggered from {MnemeJob} after an L1 snapshot is created, when enough
9
+ # uncovered L1 snapshots have accumulated (configurable via
10
+ # +mneme.l2_snapshot_threshold+ in config.toml).
11
+ #
12
+ # @example
13
+ # Mneme::L2Runner.new(session).call
14
+ class L2Runner
15
+ TOOLS = [
16
+ Tools::SaveSnapshot,
17
+ Tools::EverythingOk
18
+ ].freeze
19
+
20
+ SYSTEM_PROMPT = <<~PROMPT
21
+ You are Mneme, the memory department of an AI agent named Anima.
22
+ Your job is to compress multiple conversation summaries into a single
23
+ higher-level summary.
24
+
25
+ You MUST ONLY communicate through tool calls — NEVER output text.
26
+
27
+ ──────────────────────────────
28
+ WHAT YOU SEE
29
+ ──────────────────────────────
30
+ Several Level 1 snapshots — hourly conversation summaries.
31
+ Each captures key decisions, goals discussed, and important context
32
+ from a portion of the conversation history.
33
+
34
+ ──────────────────────────────
35
+ YOUR TASK
36
+ ──────────────────────────────
37
+ Compress the snapshots into ONE Level 2 summary that captures the
38
+ essential arc across all of them. If the snapshots contain meaningful
39
+ content, call save_snapshot. If they are purely mechanical, call
40
+ everything_ok.
41
+
42
+ Preserve:
43
+ - Key decisions and their reasoning
44
+ - Goal progress across the time span
45
+ - Important context shifts or pivots
46
+ - Relationships and patterns across snapshots
47
+
48
+ Drop:
49
+ - Redundant details repeated across snapshots
50
+ - Mechanical execution details
51
+ - Interim decisions that were superseded by later ones
52
+
53
+ Always finish with exactly ONE tool call: either save_snapshot or everything_ok.
54
+ PROMPT
55
+
56
+ # @param session [Session] the main session whose L1 snapshots to compress
57
+ # @param client [LLM::Client, nil] injectable LLM client (defaults to fast model)
58
+ def initialize(session, client: nil)
59
+ @session = session
60
+ @client = client || LLM::Client.new(
61
+ model: Anima::Settings.fast_model,
62
+ max_tokens: Anima::Settings.mneme_max_tokens,
63
+ logger: Mneme.logger
64
+ )
65
+ end
66
+
67
+ # Compresses uncovered L1 snapshots into a single L2 snapshot.
68
+ # Returns early if not enough L1 snapshots have accumulated.
69
+ #
70
+ # @return [String, nil] LLM response text, or nil when skipped
71
+ def call
72
+ l1_snapshots = eligible_snapshots
73
+ threshold = Anima::Settings.mneme_l2_snapshot_threshold
74
+ sid = @session.id
75
+ snapshot_count = l1_snapshots.size
76
+
77
+ if snapshot_count < threshold
78
+ log.debug("session=#{sid} — only #{snapshot_count}/#{threshold} L1 snapshots, skipping L2")
79
+ return
80
+ end
81
+
82
+ messages = build_messages(l1_snapshots)
83
+ registry = build_registry(l1_snapshots)
84
+
85
+ log.info("session=#{sid} — running L2 compression (#{snapshot_count} L1 snapshots)")
86
+
87
+ result = @client.chat_with_tools(
88
+ messages,
89
+ registry: registry,
90
+ session_id: nil,
91
+ system: SYSTEM_PROMPT
92
+ )
93
+
94
+ log.info("session=#{sid} — L2 compression done: #{result.to_s.truncate(200)}")
95
+ result
96
+ end
97
+
98
+ private
99
+
100
+ # L1 snapshots that are not yet covered by any L2 snapshot.
101
+ #
102
+ # @return [Array<Snapshot>]
103
+ def eligible_snapshots
104
+ @session.snapshots.for_level(1).not_covered_by_l2.chronological.to_a
105
+ end
106
+
107
+ # Frames L1 snapshot texts as a user message for the LLM.
108
+ #
109
+ # @param snapshots [Array<Snapshot>]
110
+ # @return [Array<Hash>] single-element messages array
111
+ def build_messages(snapshots)
112
+ content = snapshots.map.with_index(1) { |snap, idx|
113
+ "--- Snapshot #{idx} (events #{snap.from_event_id}..#{snap.to_event_id}) ---\n#{snap.text}"
114
+ }.join("\n\n")
115
+
116
+ [{role: "user", content: "Compress these #{snapshots.size} Level 1 snapshots into a single Level 2 summary:\n\n#{content}"}]
117
+ end
118
+
119
+ # Builds the tool registry with L2 context for SaveSnapshot.
120
+ # The event range spans from the first L1's start to the last L1's end.
121
+ #
122
+ # @param snapshots [Array<Snapshot>]
123
+ # @return [Tools::Registry]
124
+ def build_registry(snapshots)
125
+ registry = ::Tools::Registry.new(context: {
126
+ main_session: @session,
127
+ from_event_id: snapshots.first.from_event_id,
128
+ to_event_id: snapshots.last.to_event_id,
129
+ level: 2
130
+ })
131
+ TOOLS.each { |tool| registry.register(tool) }
132
+ registry
133
+ end
134
+
135
+ # @return [Logger]
136
+ def log = Mneme.logger
137
+ end
138
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
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.
7
+ #
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 humans — context surfaces without conscious effort.
11
+ #
12
+ # @example Trigger after a goal update
13
+ # Mneme::PassiveRecall.new(session).call
14
+ class PassiveRecall
15
+ # @param session [Session] the session whose goals drive recall
16
+ def initialize(session)
17
+ @session = session
18
+ end
19
+
20
+ # Searches event history using active goal descriptions as queries.
21
+ # Returns recall results suitable for viewport injection.
22
+ #
23
+ # @return [Array<Mneme::Search::Result>] deduplicated, relevance-sorted
24
+ def call
25
+ goals = @session.goals.active.root.includes(:sub_goals)
26
+ return [] if goals.empty?
27
+
28
+ search_terms = build_search_terms(goals)
29
+ return [] if search_terms.blank?
30
+
31
+ results = Mneme::Search.query(search_terms, limit: Anima::Settings.recall_max_results)
32
+
33
+ # Exclude events from the current session's viewport — no point recalling
34
+ # what the agent already sees.
35
+ viewport_ids = @session.viewport_event_ids.to_set
36
+ results.reject { |result| viewport_ids.include?(result.event_id) }
37
+ end
38
+
39
+ private
40
+
41
+ STOP_WORDS = Set.new(%w[
42
+ a an the is are was were be been being do does did
43
+ have has had in on at to for of and or but not with
44
+ this that it its by from as up out if about into
45
+ fix add create update remove implement check set get
46
+ ]).freeze
47
+
48
+ # 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
+ # @param goals [ActiveRecord::Relation<Goal>]
53
+ # @return [String] FTS5 OR-joined keywords
54
+ def build_search_terms(goals)
55
+ descriptions = goals.flat_map { |goal|
56
+ [goal.description] + goal.sub_goals.reject(&:completed?).map(&:description)
57
+ }
58
+
59
+ words = descriptions.join(" ")
60
+ .gsub(/[^a-zA-Z0-9\s-]/, "")
61
+ .downcase
62
+ .split
63
+ .uniq
64
+ .reject { |word| STOP_WORDS.include?(word) || word.length < 3 }
65
+
66
+ words.join(" OR ").truncate(500)
67
+ end
68
+ end
69
+ end