anima-core 1.0.2 → 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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +47 -0
  4. data/README.md +60 -26
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +29 -10
  7. data/app/decorators/tool_call_decorator.rb +7 -3
  8. data/app/decorators/tool_decorator.rb +57 -0
  9. data/app/decorators/tool_response_decorator.rb +12 -4
  10. data/app/decorators/web_get_tool_decorator.rb +102 -0
  11. data/app/jobs/agent_request_job.rb +90 -23
  12. data/app/jobs/mneme_job.rb +51 -0
  13. data/app/jobs/passive_recall_job.rb +29 -0
  14. data/app/models/concerns/event/broadcasting.rb +18 -0
  15. data/app/models/event.rb +10 -0
  16. data/app/models/goal.rb +27 -0
  17. data/app/models/goal_pinned_event.rb +11 -0
  18. data/app/models/pinned_event.rb +41 -0
  19. data/app/models/session.rb +335 -6
  20. data/app/models/snapshot.rb +76 -0
  21. data/config/initializers/event_subscribers.rb +14 -3
  22. data/config/initializers/fts5_schema_dump.rb +21 -0
  23. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  24. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  25. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  26. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  27. data/lib/agent_loop.rb +63 -20
  28. data/lib/analytical_brain/runner.rb +158 -65
  29. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  30. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  31. data/lib/anima/cli.rb +2 -1
  32. data/lib/anima/installer.rb +11 -12
  33. data/lib/anima/settings.rb +41 -0
  34. data/lib/anima/version.rb +1 -1
  35. data/lib/events/bounce_back.rb +37 -0
  36. data/lib/events/subscribers/agent_dispatcher.rb +29 -0
  37. data/lib/events/subscribers/persister.rb +17 -0
  38. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  39. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  40. data/lib/llm/client.rb +16 -8
  41. data/lib/mneme/compressed_viewport.rb +200 -0
  42. data/lib/mneme/l2_runner.rb +138 -0
  43. data/lib/mneme/passive_recall.rb +69 -0
  44. data/lib/mneme/runner.rb +254 -0
  45. data/lib/mneme/search.rb +150 -0
  46. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  47. data/lib/mneme/tools/everything_ok.rb +24 -0
  48. data/lib/mneme/tools/save_snapshot.rb +68 -0
  49. data/lib/mneme.rb +29 -0
  50. data/lib/providers/anthropic.rb +57 -13
  51. data/lib/shell_session.rb +188 -59
  52. data/lib/tasks/fts5.rake +6 -0
  53. data/lib/tools/remember.rb +179 -0
  54. data/lib/tools/spawn_specialist.rb +21 -9
  55. data/lib/tools/spawn_subagent.rb +22 -11
  56. data/lib/tools/subagent_prompts.rb +20 -3
  57. data/lib/tools/web_get.rb +15 -6
  58. data/lib/tui/app.rb +222 -125
  59. data/lib/tui/decorators/base_decorator.rb +165 -0
  60. data/lib/tui/decorators/bash_decorator.rb +20 -0
  61. data/lib/tui/decorators/edit_decorator.rb +19 -0
  62. data/lib/tui/decorators/read_decorator.rb +24 -0
  63. data/lib/tui/decorators/think_decorator.rb +36 -0
  64. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  65. data/lib/tui/decorators/write_decorator.rb +19 -0
  66. data/lib/tui/flash.rb +139 -0
  67. data/lib/tui/formatting.rb +28 -0
  68. data/lib/tui/height_map.rb +93 -0
  69. data/lib/tui/message_store.rb +25 -1
  70. data/lib/tui/performance_logger.rb +90 -0
  71. data/lib/tui/screens/chat.rb +358 -133
  72. data/templates/config.toml +40 -0
  73. metadata +83 -4
  74. data/CHANGELOG.md +0 -80
  75. data/Gemfile +0 -17
  76. data/lib/tools/return_result.rb +0 -81
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ # Transient failure event emitted when LLM delivery fails inside the
5
+ # Bounce Back transaction. The user event record is rolled back, and
6
+ # this event notifies clients to remove the phantom message and
7
+ # restore the text to the input field.
8
+ #
9
+ # Not persisted — not included in {Event::TYPES}.
10
+ class BounceBack < Base
11
+ TYPE = "bounce_back"
12
+
13
+ # @return [String] human-readable error description
14
+ attr_reader :error
15
+
16
+ # @return [Integer, nil] database ID of the rolled-back event (for client-side removal)
17
+ attr_reader :event_id
18
+
19
+ # @param content [String] original user message text to restore to input
20
+ # @param error [String] error description for the flash message
21
+ # @param session_id [Integer] session the message was intended for
22
+ # @param event_id [Integer, nil] ID of the event that was broadcast optimistically
23
+ def initialize(content:, error:, session_id:, event_id: nil)
24
+ super(content: content, session_id: session_id)
25
+ @error = error
26
+ @event_id = event_id
27
+ end
28
+
29
+ def type
30
+ TYPE
31
+ end
32
+
33
+ def to_h
34
+ super.merge(error: error, event_id: event_id)
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Reacts to non-pending {Events::UserMessage} emissions by scheduling
6
+ # {AgentRequestJob}. This is the event-driven bridge between the
7
+ # channel (which emits the intent) and the job (which persists and
8
+ # delivers the message).
9
+ #
10
+ # Pending messages are skipped — they are picked up by the running
11
+ # agent loop after it finishes the current turn.
12
+ class AgentDispatcher
13
+ include Events::Subscriber
14
+
15
+ # @param event [Hash] Rails.event notification hash
16
+ def emit(event)
17
+ payload = event[:payload]
18
+ return unless payload.is_a?(Hash)
19
+ return unless payload[:type] == "user_message"
20
+ return if payload[:status] == Event::PENDING_STATUS
21
+
22
+ session_id = payload[:session_id]
23
+ return unless session_id
24
+
25
+ AgentRequestJob.perform_later(session_id, content: payload[:content])
26
+ end
27
+ end
28
+ end
29
+ end
@@ -27,6 +27,12 @@ module Events
27
27
  end
28
28
 
29
29
  # Receives a Rails.event notification hash and persists it.
30
+ #
31
+ # Skips non-pending user messages — those are persisted by
32
+ # {AgentRequestJob} inside a transaction with LLM delivery
33
+ # (Bounce Back, #236). Also skips event types not in {Event::TYPES}
34
+ # (transient events like {Events::BounceBack}).
35
+ #
30
36
  # @param event [Hash] with :payload containing event data
31
37
  def emit(event)
32
38
  payload = event[:payload]
@@ -34,6 +40,8 @@ module Events
34
40
 
35
41
  event_type = payload[:type]
36
42
  return if event_type.nil?
43
+ return unless Event::TYPES.include?(event_type)
44
+ return if persisted_by_job?(event_type, payload)
37
45
 
38
46
  target_session = @session || Session.find_by(id: payload[:session_id])
39
47
  return unless target_session
@@ -52,6 +60,15 @@ module Events
52
60
  def session=(new_session)
53
61
  @mutex.synchronize { @session = new_session }
54
62
  end
63
+
64
+ private
65
+
66
+ # Non-pending user messages are persisted by {AgentRequestJob} inside
67
+ # a transaction with LLM delivery. Pending messages are still
68
+ # auto-persisted here because they queue while the session is busy.
69
+ def persisted_by_job?(event_type, payload)
70
+ event_type == "user_message" && payload[:status] != Event::PENDING_STATUS
71
+ end
55
72
  end
56
73
  end
57
74
  end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Routes text messages between parent and child sessions, enabling
6
+ # bidirectional @mention communication.
7
+ #
8
+ # **Child → Parent:** When a sub-agent emits an {Events::AgentMessage},
9
+ # the router persists a {Events::UserMessage} in the parent session
10
+ # with attribution prefix, then wakes the parent via {AgentRequestJob}.
11
+ #
12
+ # **Parent → Child:** When a parent agent emits an {Events::AgentMessage}
13
+ # containing `@name` mentions, the router persists the message in each
14
+ # matching child session and wakes them via {AgentRequestJob}.
15
+ #
16
+ # Both directions use direct persistence + job enqueue (same pattern as
17
+ # {Tools::SpawnSubagent#spawn_child}) to avoid conflicts with the global
18
+ # {Persister} which skips non-pending user messages.
19
+ #
20
+ # This replaces the +return_result+ tool — sub-agents communicate
21
+ # through natural text messages instead of structured tool calls.
22
+ class SubagentMessageRouter
23
+ include Events::Subscriber
24
+
25
+ # Attribution prefix format for messages routed from child to parent.
26
+ # @example "[sub-agent @loop-sleuth]: Here's what I found..."
27
+ ATTRIBUTION_FORMAT = "[sub-agent @%s]: %s"
28
+
29
+ # Regex to extract @mention names from parent agent messages.
30
+ MENTION_PATTERN = /@(\w[\w-]*)/
31
+
32
+ # Routes agent text messages between parent and child sessions.
33
+ #
34
+ # For sub-agent sessions: forwards to parent with attribution prefix.
35
+ # For parent sessions: scans for @mentions and routes to matching children.
36
+ #
37
+ # @param event [Hash] Rails.event notification hash with +:payload+ containing
38
+ # an +agent_message+ event (type, session_id, content)
39
+ # @return [void]
40
+ def emit(event)
41
+ payload = event[:payload]
42
+ return unless payload.is_a?(Hash)
43
+ return unless payload[:type] == "agent_message"
44
+
45
+ session_id = payload[:session_id]
46
+ return unless session_id
47
+
48
+ content = payload[:content].to_s
49
+ return if content.empty?
50
+
51
+ session = Session.find_by(id: session_id)
52
+ return unless session
53
+
54
+ if session.sub_agent?
55
+ route_to_parent(session, content)
56
+ else
57
+ route_mentions_to_children(session, content)
58
+ end
59
+ end
60
+
61
+ private
62
+
63
+ # Forwards a sub-agent's text message to its parent session.
64
+ # Persists directly and enqueues a job so the parent agent wakes
65
+ # up to process the message.
66
+ #
67
+ # @param child [Session] the sub-agent session
68
+ # @param content [String] the sub-agent's message text
69
+ def route_to_parent(child, content)
70
+ parent = child.parent_session
71
+ return unless parent
72
+
73
+ name = child.name || "agent-#{child.id}"
74
+ attributed = format(ATTRIBUTION_FORMAT, name, content)
75
+
76
+ parent.create_user_event(attributed)
77
+ AgentRequestJob.perform_later(parent.id)
78
+ end
79
+
80
+ # Scans a parent agent's message for @mentions and routes the message
81
+ # to each mentioned child session.
82
+ #
83
+ # @param parent [Session] the parent session
84
+ # @param content [String] the parent agent's message text
85
+ def route_mentions_to_children(parent, content)
86
+ mentioned_names = content.scan(MENTION_PATTERN).flatten.uniq
87
+ return if mentioned_names.empty?
88
+
89
+ active_children = parent.child_sessions.where.not(name: nil).index_by(&:name)
90
+ return if active_children.empty?
91
+
92
+ mentioned_names.each do |name|
93
+ child = active_children[name]
94
+ next unless child
95
+
96
+ child.create_user_event(content)
97
+ AgentRequestJob.perform_later(child.id)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Events
4
+ module Subscribers
5
+ # Bridges transient (non-persisted) events to ActionCable so clients
6
+ # receive them over WebSocket. Persisted events reach clients via
7
+ # {Event::Broadcasting} callbacks; this subscriber handles events
8
+ # that never touch the database.
9
+ #
10
+ # @example Registering at boot
11
+ # Events::Bus.subscribe(Events::Subscribers::TransientBroadcaster.new)
12
+ class TransientBroadcaster
13
+ include Events::Subscriber
14
+
15
+ # Event types that are broadcast without persistence.
16
+ TRANSIENT_TYPES = [Events::BounceBack::TYPE].freeze
17
+
18
+ # @param event [Hash] Rails.event notification hash
19
+ def emit(event)
20
+ payload = event[:payload]
21
+ return unless payload.is_a?(Hash)
22
+
23
+ event_type = payload[:type]
24
+ return unless TRANSIENT_TYPES.include?(event_type)
25
+
26
+ session_id = payload[:session_id]
27
+ return unless session_id
28
+
29
+ ActionCable.server.broadcast(
30
+ "session_#{session_id}",
31
+ payload.transform_keys(&:to_s)
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end
data/lib/llm/client.rb CHANGED
@@ -70,10 +70,13 @@ module LLM
70
70
  # @param messages [Array<Hash>] conversation messages in Anthropic format
71
71
  # @param registry [Tools::Registry] registered tools to make available
72
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.
73
76
  # @param options [Hash] additional API parameters (e.g. +system:+)
74
77
  # @return [String, nil] the assistant's final text response, or nil when interrupted
75
78
  # @raise [Providers::Anthropic::Error] on API errors
76
- def chat_with_tools(messages, registry:, session_id:, **options)
79
+ def chat_with_tools(messages, registry:, session_id:, first_response: nil, **options)
77
80
  messages = messages.dup
78
81
  rounds = 0
79
82
 
@@ -84,13 +87,17 @@ module LLM
84
87
  return "[Tool loop exceeded #{max_rounds} rounds — halting]"
85
88
  end
86
89
 
87
- response = provider.create_message(
88
- model: model,
89
- messages: messages,
90
- max_tokens: max_tokens,
91
- tools: registry.schemas,
92
- **options
93
- )
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
94
101
 
95
102
  log(:debug, "stop_reason=#{response["stop_reason"]} content_types=#{(response["content"] || []).map { |b| b["type"] }.join(",")}")
96
103
 
@@ -189,6 +196,7 @@ module LLM
189
196
  {error: "#{error.class}: #{error.message}"}
190
197
  end
191
198
 
199
+ result = ToolDecorator.call(name, result)
192
200
  result_content = format_tool_result(result)
193
201
  log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
194
202
 
@@ -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