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,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
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ # Full-text search over event history using SQLite FTS5.
5
+ # Covers user messages, agent messages, and think events across all sessions.
6
+ #
7
+ # The interface is intentionally abstract — callers receive {Result} structs
8
+ # and never touch FTS5 directly. A future semantic search backend (embeddings,
9
+ # BM25 + re-ranking) can replace the implementation without changing callers.
10
+ #
11
+ # @example Search across all sessions
12
+ # results = Mneme::Search.query("authentication flow")
13
+ # results.each { |r| puts "event #{r.event_id}: #{r.snippet}" }
14
+ #
15
+ # @example Search within a single session
16
+ # results = Mneme::Search.query("OAuth config", session_id: 42)
17
+ class Search
18
+ # A single search result with enough context for display and drill-down.
19
+ #
20
+ # @!attribute event_id [Integer] the event's database ID
21
+ # @!attribute session_id [Integer] the session owning this event
22
+ # @!attribute snippet [String] highlighted excerpt from the matching content
23
+ # @!attribute rank [Float] FTS5 relevance score (lower = more relevant)
24
+ # @!attribute event_type [String] one of Event::TYPES
25
+ Result = Struct.new(:event_id, :session_id, :snippet, :rank, :event_type, keyword_init: true)
26
+
27
+ # Searches event history for the given terms.
28
+ #
29
+ # @param terms [String] search query (FTS5 syntax: words, phrases, OR/AND/NOT)
30
+ # @param session_id [Integer, nil] scope to a specific session (nil = all sessions)
31
+ # @param limit [Integer] maximum results
32
+ # @return [Array<Result>] ranked by relevance (best first)
33
+ def self.query(terms, session_id: nil, limit: Anima::Settings.recall_max_results)
34
+ new(terms, session_id: session_id, limit: limit).call
35
+ end
36
+
37
+ def initialize(terms, session_id: nil, limit: 5)
38
+ @terms = sanitize_query(terms)
39
+ @session_id = session_id
40
+ @limit = limit
41
+ end
42
+
43
+ # @return [Array<Result>] ranked by relevance (best first)
44
+ def call
45
+ return [] if @terms.blank?
46
+
47
+ rows = execute_fts_query
48
+ rows.map { |row| build_result(row) }
49
+ end
50
+
51
+ private
52
+
53
+ # Executes the FTS5 MATCH query with optional session scoping.
54
+ # Joins back to events table for session_id and event_type.
55
+ #
56
+ # @return [Array<Hash>] raw database rows
57
+ def execute_fts_query
58
+ if @session_id
59
+ connection.select_all(scoped_sql, "Mneme::Search", [@terms, @session_id, @limit]).to_a
60
+ else
61
+ connection.select_all(global_sql, "Mneme::Search", [@terms, @limit]).to_a
62
+ end
63
+ end
64
+
65
+ # FTS5 query across all sessions.
66
+ # Contentless FTS5 can't use snippet() — extract content from events directly.
67
+ def global_sql
68
+ <<~SQL
69
+ SELECT
70
+ e.id AS event_id,
71
+ e.session_id,
72
+ e.event_type,
73
+ CASE
74
+ WHEN e.event_type IN ('user_message', 'agent_message', 'system_message')
75
+ THEN substr(json_extract(e.payload, '$.content'), 1, 300)
76
+ WHEN e.event_type = 'tool_call'
77
+ THEN substr(json_extract(e.payload, '$.tool_input.thoughts'), 1, 300)
78
+ END AS snippet,
79
+ rank
80
+ FROM events_fts
81
+ JOIN events e ON e.id = events_fts.rowid
82
+ WHERE events_fts MATCH ?
83
+ ORDER BY rank
84
+ LIMIT ?
85
+ SQL
86
+ end
87
+
88
+ # FTS5 query scoped to a specific session.
89
+ def scoped_sql
90
+ <<~SQL
91
+ SELECT
92
+ e.id AS event_id,
93
+ e.session_id,
94
+ e.event_type,
95
+ CASE
96
+ WHEN e.event_type IN ('user_message', 'agent_message', 'system_message')
97
+ THEN substr(json_extract(e.payload, '$.content'), 1, 300)
98
+ WHEN e.event_type = 'tool_call'
99
+ THEN substr(json_extract(e.payload, '$.tool_input.thoughts'), 1, 300)
100
+ END AS snippet,
101
+ rank
102
+ FROM events_fts
103
+ JOIN events e ON e.id = events_fts.rowid
104
+ WHERE events_fts MATCH ?
105
+ AND e.session_id = ?
106
+ ORDER BY rank
107
+ LIMIT ?
108
+ SQL
109
+ end
110
+
111
+ # Builds a Result from a raw database row.
112
+ #
113
+ # @param row [Hash]
114
+ # @return [Result]
115
+ def build_result(row)
116
+ Result.new(
117
+ event_id: row["event_id"],
118
+ session_id: row["session_id"],
119
+ snippet: row["snippet"],
120
+ rank: row["rank"],
121
+ event_type: row["event_type"]
122
+ )
123
+ end
124
+
125
+ # Sanitizes user input for FTS5 MATCH safety.
126
+ # Strips special FTS5 operators that could cause syntax errors,
127
+ # keeps only alphanumeric words and quoted phrases.
128
+ #
129
+ # @param raw [String]
130
+ # @return [String] safe FTS5 query
131
+ def sanitize_query(raw)
132
+ return "" unless raw
133
+
134
+ # Extract quoted phrases and individual words, drop FTS5 operators
135
+ tokens = raw.scan(/"[^"]+?"|\S+/).reject { |token| token.match?(/\A[*:^{}()]+\z/) }
136
+ tokens.filter_map { |token| sanitize_token(token) }.join(" ")
137
+ end
138
+
139
+ def sanitize_token(token)
140
+ return token if token.start_with?('"')
141
+
142
+ cleaned = token.gsub(/[^a-zA-Z0-9-]/, "")
143
+ cleaned.empty? ? nil : cleaned
144
+ end
145
+
146
+ def connection
147
+ ActiveRecord::Base.connection
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ module Tools
5
+ # Pins critical events to active Goals so they survive viewport eviction.
6
+ # Mneme calls this when it sees important events (user instructions, key
7
+ # decisions, critical corrections) approaching the eviction zone.
8
+ #
9
+ # Events are pinned via a many-to-many join: one event can be attached
10
+ # to multiple Goals. When all referencing Goals complete, the pin is
11
+ # automatically released (reference-counted cleanup in {Goal#release_orphaned_pins!}).
12
+ class AttachEventsToGoals < ::Tools::Base
13
+ def self.tool_name = "attach_events_to_goals"
14
+
15
+ def self.description = "Pin critical events to active goals so they survive " \
16
+ "viewport eviction. Use this for events that are too important to lose — " \
17
+ "exact user instructions, key decisions, critical corrections. " \
18
+ "Events stay pinned until all attached goals complete."
19
+
20
+ def self.input_schema
21
+ {
22
+ type: "object",
23
+ properties: {
24
+ event_ids: {
25
+ type: "array",
26
+ items: {type: "integer"},
27
+ description: "Database IDs of events to pin (from `event N` prefixes in the viewport)"
28
+ },
29
+ goal_ids: {
30
+ type: "array",
31
+ items: {type: "integer"},
32
+ description: "IDs of active goals to attach the events to"
33
+ }
34
+ },
35
+ required: %w[event_ids goal_ids]
36
+ }
37
+ end
38
+
39
+ # @param main_session [Session] the session being observed
40
+ def initialize(main_session:, **)
41
+ @session = main_session
42
+ end
43
+
44
+ # @param input [Hash<String, Object>] with "event_ids" and "goal_ids"
45
+ # @return [String] confirmation with link count, or error description
46
+ def execute(input)
47
+ event_ids = Array(input["event_ids"]).map(&:to_i).uniq
48
+ goal_ids = Array(input["goal_ids"]).map(&:to_i).uniq
49
+
50
+ return "Error: event_ids cannot be empty" if event_ids.empty?
51
+ return "Error: goal_ids cannot be empty" if goal_ids.empty?
52
+
53
+ events = @session.events.where(id: event_ids)
54
+ goals = @session.goals.active.where(id: goal_ids)
55
+
56
+ missing_events = event_ids - events.pluck(:id)
57
+ inactive_goal_ids = goal_ids - goals.pluck(:id)
58
+
59
+ errors = []
60
+ errors << "Events not found: #{missing_events.join(", ")}" if missing_events.any?
61
+
62
+ if inactive_goal_ids.any?
63
+ completed_ids = @session.goals.completed.where(id: inactive_goal_ids).pluck(:id)
64
+ not_found_ids = inactive_goal_ids - completed_ids
65
+ errors << "Goals already completed: #{completed_ids.join(", ")}" if completed_ids.any?
66
+ errors << "Goals not found: #{not_found_ids.join(", ")}" if not_found_ids.any?
67
+ end
68
+
69
+ return "Error: #{errors.join("; ")}" if errors.any?
70
+
71
+ attached = attach(events, goals)
72
+ "Pinned #{attached} event-goal links"
73
+ end
74
+
75
+ private
76
+
77
+ def attach(events, goals)
78
+ events.sum do |event|
79
+ pinned = find_or_create_pinned_event(event)
80
+ link_to_goals(pinned, goals)
81
+ end
82
+ end
83
+
84
+ def link_to_goals(pinned, goals)
85
+ goals.each { |goal| GoalPinnedEvent.find_or_create_by!(goal: goal, pinned_event: pinned) }
86
+ goals.size
87
+ end
88
+
89
+ def find_or_create_pinned_event(event)
90
+ PinnedEvent.find_or_create_by!(event: event) do |pe|
91
+ pe.display_text = truncate_event_content(event)
92
+ end
93
+ end
94
+
95
+ def truncate_event_content(event)
96
+ content = event.payload&.dig("content").to_s.strip
97
+ content = "event #{event.id}" if content.empty?
98
+
99
+ if content.length > PinnedEvent::MAX_DISPLAY_TEXT_LENGTH
100
+ content[0, PinnedEvent::MAX_DISPLAY_TEXT_LENGTH - 1] + "…"
101
+ else
102
+ content
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ module Tools
5
+ # Sentinel tool signaling that Mneme has reviewed the viewport and
6
+ # determined no snapshot is needed. Called when the conversation
7
+ # context doesn't contain enough meaningful content to summarize.
8
+ class EverythingOk < ::Tools::Base
9
+ def self.tool_name = "everything_ok"
10
+
11
+ def self.description = "Signal that no snapshot is needed. " \
12
+ "Call this when the eviction zone contains only mechanical " \
13
+ "activity (tool calls) with no meaningful conversation to summarize."
14
+
15
+ def self.input_schema
16
+ {type: "object", properties: {}, required: []}
17
+ end
18
+
19
+ def execute(_input)
20
+ "Acknowledged. No snapshot needed."
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ module Tools
5
+ # Saves a summary snapshot of conversation context that is about to
6
+ # leave the viewport. The snapshot captures the "gist" of what happened
7
+ # so the agent retains awareness of past context.
8
+ #
9
+ # The text field has a max_tokens limit for predictable sizing — each
10
+ # snapshot is a fixed-size tile, enabling calculation of how many fit
11
+ # at each compression level.
12
+ class SaveSnapshot < ::Tools::Base
13
+ def self.tool_name = "save_snapshot"
14
+
15
+ def self.description = "Save a summary of the conversation context " \
16
+ "that is about to leave the viewport. Write a concise summary " \
17
+ "capturing key decisions, topics discussed, and important context. " \
18
+ "Focus on WHAT was decided and WHY, not mechanical details."
19
+
20
+ def self.input_schema
21
+ {
22
+ type: "object",
23
+ properties: {
24
+ text: {
25
+ type: "string",
26
+ description: "The summary text. Be concise but preserve key decisions, " \
27
+ "goals discussed, and important context. Max #{Anima::Settings.mneme_max_tokens} tokens."
28
+ }
29
+ },
30
+ required: %w[text]
31
+ }
32
+ end
33
+
34
+ # @param main_session [Session] the session being observed
35
+ # @param from_event_id [Integer] first event ID covered by this snapshot
36
+ # @param to_event_id [Integer] last event ID covered by this snapshot
37
+ # @param level [Integer] compression level (1 = from events, 2 = from L1 snapshots)
38
+ def initialize(main_session:, from_event_id:, to_event_id:, level: 1, **)
39
+ @main_session = main_session
40
+ @from_event_id = from_event_id
41
+ @to_event_id = to_event_id
42
+ @level = level
43
+ end
44
+
45
+ def execute(input)
46
+ text = input["text"].to_s.strip
47
+ return "Error: Summary text cannot be blank" if text.empty?
48
+
49
+ snapshot = @main_session.snapshots.create!(
50
+ text: text,
51
+ from_event_id: @from_event_id,
52
+ to_event_id: @to_event_id,
53
+ level: @level,
54
+ token_count: estimate_tokens(text)
55
+ )
56
+
57
+ "Snapshot saved (id: #{snapshot.id}, events #{@from_event_id}..#{@to_event_id})"
58
+ end
59
+
60
+ private
61
+
62
+ # @return [Integer] estimated token count for the summary text
63
+ def estimate_tokens(text)
64
+ [(text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
65
+ end
66
+ end
67
+ end
68
+ end