anima-core 1.0.2 → 1.1.1

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 (85) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +51 -0
  4. data/README.md +63 -29
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +30 -11
  7. data/app/decorators/tool_call_decorator.rb +32 -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 +93 -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 +4 -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 +402 -6
  20. data/app/models/snapshot.rb +76 -0
  21. data/bin/jobs +5 -0
  22. data/config/initializers/event_subscribers.rb +12 -3
  23. data/config/initializers/fts5_schema_dump.rb +21 -0
  24. data/config/queue.yml +0 -1
  25. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  26. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  27. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  28. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  29. data/lib/agent_loop.rb +63 -20
  30. data/lib/analytical_brain/runner.rb +158 -65
  31. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  32. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  33. data/lib/anima/cli.rb +32 -9
  34. data/lib/anima/installer.rb +11 -24
  35. data/lib/anima/settings.rb +59 -0
  36. data/lib/anima/spinner.rb +75 -0
  37. data/lib/anima/version.rb +1 -1
  38. data/lib/environment_probe.rb +4 -4
  39. data/lib/events/bounce_back.rb +37 -0
  40. data/lib/events/subscribers/persister.rb +19 -0
  41. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  42. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  43. data/lib/events/tool_call.rb +5 -3
  44. data/lib/llm/client.rb +19 -9
  45. data/lib/mneme/compressed_viewport.rb +200 -0
  46. data/lib/mneme/l2_runner.rb +138 -0
  47. data/lib/mneme/passive_recall.rb +69 -0
  48. data/lib/mneme/runner.rb +254 -0
  49. data/lib/mneme/search.rb +150 -0
  50. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  51. data/lib/mneme/tools/everything_ok.rb +24 -0
  52. data/lib/mneme/tools/save_snapshot.rb +68 -0
  53. data/lib/mneme.rb +29 -0
  54. data/lib/providers/anthropic.rb +57 -13
  55. data/lib/shell_session.rb +194 -63
  56. data/lib/tasks/fts5.rake +6 -0
  57. data/lib/tools/base.rb +2 -1
  58. data/lib/tools/bash.rb +4 -2
  59. data/lib/tools/registry.rb +22 -3
  60. data/lib/tools/remember.rb +179 -0
  61. data/lib/tools/request_feature.rb +3 -1
  62. data/lib/tools/spawn_specialist.rb +21 -9
  63. data/lib/tools/spawn_subagent.rb +22 -11
  64. data/lib/tools/subagent_prompts.rb +20 -3
  65. data/lib/tools/web_get.rb +21 -10
  66. data/lib/tui/app.rb +222 -125
  67. data/lib/tui/decorators/base_decorator.rb +165 -0
  68. data/lib/tui/decorators/bash_decorator.rb +20 -0
  69. data/lib/tui/decorators/edit_decorator.rb +19 -0
  70. data/lib/tui/decorators/read_decorator.rb +24 -0
  71. data/lib/tui/decorators/think_decorator.rb +36 -0
  72. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  73. data/lib/tui/decorators/write_decorator.rb +19 -0
  74. data/lib/tui/flash.rb +139 -0
  75. data/lib/tui/formatting.rb +28 -0
  76. data/lib/tui/height_map.rb +93 -0
  77. data/lib/tui/message_store.rb +97 -8
  78. data/lib/tui/performance_logger.rb +90 -0
  79. data/lib/tui/screens/chat.rb +358 -133
  80. data/templates/config.toml +47 -0
  81. data/templates/soul.md +1 -1
  82. metadata +83 -4
  83. data/CHANGELOG.md +0 -80
  84. data/Gemfile +0 -17
  85. data/lib/tools/return_result.rb +0 -81
@@ -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
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ # Orchestrates the Mneme memory department — a phantom (non-persisted) LLM loop
5
+ # that observes a main session's compressed viewport and creates summaries of
6
+ # conversation context before it evicts from the viewport.
7
+ #
8
+ # Mneme is triggered when the terminal event (`mneme_boundary_event_id`) leaves
9
+ # the viewport. It receives a compressed viewport (no raw tool calls, zone
10
+ # delimiters present) and uses the `save_snapshot` tool to persist a summary.
11
+ #
12
+ # After completing, Mneme advances the terminal event to the boundary of what
13
+ # it just summarized, so the cycle repeats as more events accumulate.
14
+ #
15
+ # @example
16
+ # Mneme::Runner.new(session).call
17
+ class Runner
18
+ TOOLS = [
19
+ Tools::SaveSnapshot,
20
+ Tools::AttachEventsToGoals,
21
+ Tools::EverythingOk
22
+ ].freeze
23
+
24
+ SYSTEM_PROMPT = <<~PROMPT
25
+ You are Mneme, the memory department of an AI agent named Anima.
26
+ Your job is to create concise summaries of conversation context that is
27
+ about to leave the agent's context window.
28
+
29
+ You MUST ONLY communicate through tool calls — NEVER output text.
30
+
31
+ ──────────────────────────────
32
+ WHAT YOU SEE
33
+ ──────────────────────────────
34
+ A compressed viewport with three zones:
35
+ - EVICTION ZONE: Events about to leave the viewport. Summarize these.
36
+ - MIDDLE ZONE: Events still visible but aging. Note key context.
37
+ - RECENT ZONE: Fresh events. Use for continuity with the summary.
38
+
39
+ Events are prefixed with `event N` (their database ID).
40
+ Tool calls are compressed to `[N tools called]` — the mechanical work
41
+ is not important, only the conversation flow.
42
+
43
+ ──────────────────────────────
44
+ YOUR TASK
45
+ ──────────────────────────────
46
+ 1. Read the eviction zone carefully.
47
+ 2. If it contains meaningful conversation (decisions, goals, context):
48
+ Call save_snapshot with a concise summary.
49
+ 3. If any events in the eviction zone are too important to summarize
50
+ (exact user instructions, critical corrections, key decisions),
51
+ pin them to active goals with attach_events_to_goals.
52
+ Pinned events survive eviction intact — use this sparingly for
53
+ events where the exact wording matters.
54
+ 4. If it contains only mechanical activity with no conversation:
55
+ Call everything_ok.
56
+
57
+ You may call BOTH save_snapshot AND attach_events_to_goals in one turn
58
+ when the zone has a mix of summarizable and pin-worthy events.
59
+
60
+ Write summaries that capture:
61
+ - What was discussed and decided
62
+ - Why decisions were made
63
+ - Active goals and their progress
64
+ - Key context the agent would need later
65
+
66
+ Do NOT include:
67
+ - Tool call details (which files were read, commands run)
68
+ - Mechanical execution steps
69
+ - Verbatim quotes (paraphrase instead)
70
+
71
+ Always finish with at least one tool call: save_snapshot, attach_events_to_goals,
72
+ or everything_ok. You may combine save_snapshot with attach_events_to_goals.
73
+ PROMPT
74
+
75
+ # @param session [Session] the main session to observe
76
+ # @param client [LLM::Client, nil] injectable LLM client (defaults to fast model)
77
+ def initialize(session, client: nil)
78
+ @session = session
79
+ @client = client || LLM::Client.new(
80
+ model: Anima::Settings.fast_model,
81
+ max_tokens: Anima::Settings.mneme_max_tokens,
82
+ logger: Mneme.logger
83
+ )
84
+ end
85
+
86
+ # Runs the Mneme loop: builds compressed viewport, calls LLM, executes
87
+ # snapshot tool, then advances the terminal event pointer.
88
+ #
89
+ # @return [String, nil] the LLM's final text response (discarded),
90
+ # or nil if no context is available
91
+ def call
92
+ viewport = build_compressed_viewport
93
+ compressed_text = viewport.render
94
+ sid = @session.id
95
+
96
+ if compressed_text.empty?
97
+ log.debug("session=#{sid} — no events for Mneme, skipping")
98
+ return
99
+ end
100
+
101
+ messages = build_messages(compressed_text)
102
+ system = SYSTEM_PROMPT
103
+
104
+ log.info("session=#{sid} — running Mneme (#{viewport.events.size} events)")
105
+ log.debug("compressed viewport:\n#{compressed_text}")
106
+
107
+ result = @client.chat_with_tools(
108
+ messages,
109
+ registry: build_registry(viewport),
110
+ session_id: nil,
111
+ system: system
112
+ )
113
+
114
+ advance_boundary(viewport)
115
+ log.info("session=#{sid} — Mneme done: #{result.to_s.truncate(200)}")
116
+ result
117
+ end
118
+
119
+ private
120
+
121
+ # Builds the compressed viewport starting from the session's boundary event.
122
+ #
123
+ # @return [Mneme::CompressedViewport]
124
+ def build_compressed_viewport
125
+ token_budget = (Anima::Settings.token_budget * Anima::Settings.mneme_viewport_fraction).to_i
126
+
127
+ CompressedViewport.new(
128
+ @session,
129
+ token_budget: token_budget,
130
+ from_event_id: @session.mneme_boundary_event_id
131
+ )
132
+ end
133
+
134
+ # Frames the compressed viewport as a user message for the LLM.
135
+ #
136
+ # @param compressed_text [String] the rendered compressed viewport
137
+ # @return [Array<Hash>] single-element messages array
138
+ def build_messages(compressed_text)
139
+ goals_context = active_goals_section
140
+
141
+ content = <<~MSG.strip
142
+ Here is the compressed viewport of the main session:
143
+
144
+ #{compressed_text}
145
+ #{goals_context}
146
+ Review the eviction zone and decide whether to save a snapshot or signal everything_ok.
147
+ MSG
148
+
149
+ [{role: "user", content: content}]
150
+ end
151
+
152
+ # Builds the tool registry with session context for SaveSnapshot.
153
+ # Passes the event range from the viewport so the snapshot records
154
+ # which events it covers.
155
+ #
156
+ # @param viewport [Mneme::CompressedViewport]
157
+ # @return [Tools::Registry]
158
+ def build_registry(viewport)
159
+ viewport_events = viewport.events
160
+ registry = ::Tools::Registry.new(context: {
161
+ main_session: @session,
162
+ from_event_id: viewport_events.first&.id,
163
+ to_event_id: viewport_events.last&.id
164
+ })
165
+ TOOLS.each { |tool| registry.register(tool) }
166
+ registry
167
+ end
168
+
169
+ # Advances the terminal event pointer after Mneme completes.
170
+ # Runs unconditionally — even when the LLM called `everything_ok` (no snapshot
171
+ # needed), the zone was reviewed and should be advanced past. Without this,
172
+ # Mneme would re-examine the same mechanical-only content on every trigger.
173
+ #
174
+ # Sets it to the last conversation event in the viewport, ensuring
175
+ # the boundary is always a message/think event, never a tool_call/tool_response.
176
+ # Also updates the snapshot range pointers.
177
+ #
178
+ # @param viewport [Mneme::CompressedViewport]
179
+ def advance_boundary(viewport)
180
+ viewport_events = viewport.events
181
+ return if viewport_events.empty?
182
+
183
+ new_boundary = viewport_events.reverse_each.find { |event| conversation_or_think?(event) }
184
+ return unless new_boundary
185
+
186
+ boundary_id = new_boundary.id
187
+ updates = {mneme_boundary_event_id: boundary_id}
188
+
189
+ updates[:mneme_snapshot_first_event_id] = viewport_events.first.id if @session.mneme_snapshot_first_event_id.nil?
190
+ updates[:mneme_snapshot_last_event_id] = viewport_events.last.id
191
+
192
+ @session.update_columns(updates)
193
+ log.debug("session=#{@session.id} — boundary advanced to event #{boundary_id}")
194
+ end
195
+
196
+ # Delegates to {Event#conversation_or_think?} — single source of truth
197
+ # for which events Mneme treats as conversation boundaries.
198
+ #
199
+ # @return [Boolean]
200
+ def conversation_or_think?(event)
201
+ event.conversation_or_think?
202
+ end
203
+
204
+ # Builds the active goals section for Mneme's context so it knows
205
+ # what Goals exist, which events are already pinned, and can reference
206
+ # them when deciding what to pin or summarize.
207
+ #
208
+ # @return [String] formatted goals section, or empty string
209
+ def active_goals_section
210
+ root_goals = @session.goals.root.includes(:sub_goals).active.order(:created_at)
211
+ return "" if root_goals.empty?
212
+
213
+ lines = root_goals.map { |goal| format_goal_for_mneme(goal) }
214
+ pinned = format_existing_pins
215
+
216
+ section = "\n\n🎯 Active Goals\n#{lines.join("\n")}\n"
217
+ section += "\n📌 Already Pinned\n#{pinned}\n" if pinned
218
+ section
219
+ end
220
+
221
+ # Formats a goal with sub-goals for Mneme's context.
222
+ #
223
+ # @param goal [Goal] root goal with preloaded sub_goals
224
+ # @return [String]
225
+ def format_goal_for_mneme(goal)
226
+ parts = [" ● #{goal.description} (id: #{goal.id})"]
227
+ goal.sub_goals.each do |sub|
228
+ checkbox = sub.completed? ? "[x]" : "[ ]"
229
+ parts << " #{checkbox} #{sub.description} (id: #{sub.id})"
230
+ end
231
+ parts.join("\n")
232
+ end
233
+
234
+ # Lists already-pinned event IDs so Mneme avoids redundant pinning.
235
+ #
236
+ # @return [String, nil] formatted pin list, or nil when nothing is pinned
237
+ def format_existing_pins
238
+ pins = @session.pinned_events.includes(:goals).order(:event_id)
239
+ return nil if pins.empty?
240
+
241
+ pins.map { |pin| format_pin_for_mneme(pin) }.join("\n")
242
+ end
243
+
244
+ # @param pin [PinnedEvent] pin with preloaded goals
245
+ # @return [String] formatted pin line
246
+ def format_pin_for_mneme(pin)
247
+ goal_ids = pin.goals.map(&:id).join(", ")
248
+ " event #{pin.event_id} → goals [#{goal_ids}]"
249
+ end
250
+
251
+ # @return [Logger]
252
+ def log = Mneme.logger
253
+ end
254
+ end