anima-core 1.1.3 → 1.3.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 (127) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +10 -1
  3. data/README.md +36 -11
  4. data/agents/codebase-analyzer.md +2 -2
  5. data/agents/codebase-pattern-finder.md +2 -2
  6. data/agents/documentation-researcher.md +2 -2
  7. data/agents/thoughts-analyzer.md +2 -2
  8. data/agents/web-search-researcher.md +3 -3
  9. data/app/channels/session_channel.rb +83 -64
  10. data/app/decorators/agent_message_decorator.rb +2 -2
  11. data/app/decorators/{event_decorator.rb → message_decorator.rb} +40 -40
  12. data/app/decorators/system_message_decorator.rb +2 -2
  13. data/app/decorators/tool_call_decorator.rb +6 -6
  14. data/app/decorators/tool_decorator.rb +4 -4
  15. data/app/decorators/tool_response_decorator.rb +2 -2
  16. data/app/decorators/user_message_decorator.rb +5 -19
  17. data/app/decorators/web_get_tool_decorator.rb +41 -9
  18. data/app/jobs/agent_request_job.rb +33 -24
  19. data/app/jobs/count_message_tokens_job.rb +39 -0
  20. data/app/jobs/passive_recall_job.rb +4 -4
  21. data/app/models/concerns/{event → message}/broadcasting.rb +16 -16
  22. data/app/models/goal.rb +17 -4
  23. data/app/models/goal_pinned_message.rb +11 -0
  24. data/app/models/message.rb +127 -0
  25. data/app/models/pending_message.rb +43 -0
  26. data/app/models/pinned_message.rb +41 -0
  27. data/app/models/secret.rb +72 -0
  28. data/app/models/session.rb +385 -226
  29. data/app/models/snapshot.rb +25 -25
  30. data/config/environments/test.rb +5 -0
  31. data/config/initializers/time_nanoseconds.rb +11 -0
  32. data/db/migrate/20260326180000_rename_event_to_message.rb +172 -0
  33. data/db/migrate/20260328100000_create_secrets.rb +15 -0
  34. data/db/migrate/20260328152142_add_evicted_at_to_goals.rb +6 -0
  35. data/db/migrate/20260329120000_create_pending_messages.rb +11 -0
  36. data/lib/agent_loop.rb +14 -41
  37. data/lib/agents/definition.rb +1 -1
  38. data/lib/analytical_brain/runner.rb +40 -37
  39. data/lib/analytical_brain/tools/activate_skill.rb +5 -9
  40. data/lib/analytical_brain/tools/assign_nickname.rb +2 -4
  41. data/lib/analytical_brain/tools/deactivate_skill.rb +5 -9
  42. data/lib/analytical_brain/tools/everything_is_ready.rb +1 -2
  43. data/lib/analytical_brain/tools/finish_goal.rb +5 -8
  44. data/lib/analytical_brain/tools/read_workflow.rb +5 -9
  45. data/lib/analytical_brain/tools/rename_session.rb +3 -10
  46. data/lib/analytical_brain/tools/set_goal.rb +3 -7
  47. data/lib/analytical_brain/tools/update_goal.rb +3 -7
  48. data/lib/anima/cli/mcp/secrets.rb +4 -4
  49. data/lib/anima/cli/mcp.rb +4 -4
  50. data/lib/anima/installer.rb +7 -1
  51. data/lib/anima/settings.rb +46 -6
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +1 -1
  54. data/lib/credential_store.rb +17 -66
  55. data/lib/events/base.rb +1 -1
  56. data/lib/events/bounce_back.rb +7 -7
  57. data/lib/events/subscribers/persister.rb +15 -22
  58. data/lib/events/subscribers/subagent_message_router.rb +20 -8
  59. data/lib/events/subscribers/transient_broadcaster.rb +2 -2
  60. data/lib/events/user_message.rb +2 -13
  61. data/lib/llm/client.rb +54 -20
  62. data/lib/mcp/config.rb +2 -2
  63. data/lib/mcp/secrets.rb +7 -8
  64. data/lib/mneme/compressed_viewport.rb +57 -57
  65. data/lib/mneme/l2_runner.rb +4 -4
  66. data/lib/mneme/passive_recall.rb +2 -2
  67. data/lib/mneme/runner.rb +57 -75
  68. data/lib/mneme/search.rb +38 -38
  69. data/lib/mneme/tools/attach_messages_to_goals.rb +103 -0
  70. data/lib/mneme/tools/everything_ok.rb +1 -3
  71. data/lib/mneme/tools/save_snapshot.rb +12 -16
  72. data/lib/shell_session.rb +54 -16
  73. data/lib/tools/base.rb +23 -0
  74. data/lib/tools/bash.rb +60 -16
  75. data/lib/tools/edit.rb +6 -8
  76. data/lib/tools/mark_goal_completed.rb +86 -0
  77. data/lib/tools/{request_feature.rb → open_issue.rb} +10 -13
  78. data/lib/tools/read.rb +6 -5
  79. data/lib/tools/recall.rb +98 -0
  80. data/lib/tools/registry.rb +37 -8
  81. data/lib/tools/remember.rb +46 -55
  82. data/lib/tools/response_truncator.rb +70 -0
  83. data/lib/tools/spawn_specialist.rb +15 -25
  84. data/lib/tools/spawn_subagent.rb +14 -22
  85. data/lib/tools/subagent_prompts.rb +42 -6
  86. data/lib/tools/think.rb +26 -10
  87. data/lib/tools/web_get.rb +23 -4
  88. data/lib/tools/write.rb +4 -4
  89. data/lib/tui/app.rb +178 -13
  90. data/lib/tui/braille_spinner.rb +152 -0
  91. data/lib/tui/cable_client.rb +4 -4
  92. data/lib/tui/decorators/base_decorator.rb +17 -8
  93. data/lib/tui/decorators/bash_decorator.rb +2 -2
  94. data/lib/tui/decorators/edit_decorator.rb +5 -4
  95. data/lib/tui/decorators/read_decorator.rb +4 -8
  96. data/lib/tui/decorators/think_decorator.rb +3 -5
  97. data/lib/tui/decorators/web_get_decorator.rb +4 -3
  98. data/lib/tui/decorators/write_decorator.rb +5 -4
  99. data/lib/tui/flash.rb +1 -1
  100. data/lib/tui/formatting.rb +22 -0
  101. data/lib/tui/message_store.rb +103 -59
  102. data/lib/tui/screens/chat.rb +293 -78
  103. data/skills/activerecord/SKILL.md +1 -1
  104. data/skills/dragonruby/SKILL.md +1 -1
  105. data/skills/draper-decorators/SKILL.md +1 -1
  106. data/skills/gh-issue.md +1 -1
  107. data/skills/mcp-server/SKILL.md +1 -1
  108. data/skills/ratatui-ruby/SKILL.md +1 -1
  109. data/skills/rspec/SKILL.md +1 -1
  110. data/templates/config.toml +42 -5
  111. data/templates/soul.md +7 -19
  112. data/workflows/create_handoff.md +1 -1
  113. data/workflows/create_note.md +1 -1
  114. data/workflows/create_plan.md +1 -1
  115. data/workflows/implement_plan.md +1 -1
  116. data/workflows/iterate_plan.md +1 -1
  117. data/workflows/research_codebase.md +1 -1
  118. data/workflows/resume_handoff.md +1 -1
  119. data/workflows/review_pr.md +78 -16
  120. data/workflows/thoughts_init.md +1 -1
  121. data/workflows/validate_plan.md +1 -1
  122. metadata +20 -9
  123. data/app/jobs/count_event_tokens_job.rb +0 -39
  124. data/app/models/event.rb +0 -129
  125. data/app/models/goal_pinned_event.rb +0 -11
  126. data/app/models/pinned_event.rb +0 -41
  127. data/lib/mneme/tools/attach_events_to_goals.rb +0 -107
@@ -32,8 +32,8 @@ module Mneme
32
32
 
33
33
  # Exclude events from the current session's viewport — no point recalling
34
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) }
35
+ viewport_ids = @session.viewport_message_ids.to_set
36
+ results.reject { |result| viewport_ids.include?(result.message_id) }
37
37
  end
38
38
 
39
39
  private
data/lib/mneme/runner.rb CHANGED
@@ -5,71 +5,53 @@ module Mneme
5
5
  # that observes a main session's compressed viewport and creates summaries of
6
6
  # conversation context before it evicts from the viewport.
7
7
  #
8
- # Mneme is triggered when the terminal event (`mneme_boundary_event_id`) leaves
8
+ # Mneme is triggered when the terminal message (`mneme_boundary_message_id`) leaves
9
9
  # the viewport. It receives a compressed viewport (no raw tool calls, zone
10
10
  # delimiters present) and uses the `save_snapshot` tool to persist a summary.
11
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.
12
+ # After completing, Mneme advances the terminal message to the boundary of what
13
+ # it just summarized, so the cycle repeats as more messages accumulate.
14
14
  #
15
15
  # @example
16
16
  # Mneme::Runner.new(session).call
17
17
  class Runner
18
18
  TOOLS = [
19
19
  Tools::SaveSnapshot,
20
- Tools::AttachEventsToGoals,
20
+ Tools::AttachMessagesToGoals,
21
21
  Tools::EverythingOk
22
22
  ].freeze
23
23
 
24
24
  SYSTEM_PROMPT = <<~PROMPT
25
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.
26
+ The agent's context is a conveyor belt events flow through and eventually fall off.
27
+ Remember what matters. Let the rest go.
28
+ Communicate only through tool calls — never output text.
30
29
 
31
30
  ──────────────────────────────
32
- WHAT YOU SEE
31
+ VIEWPORT
33
32
  ──────────────────────────────
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.
33
+ Three zones, oldest to newest:
34
+ - EVICTION ZONE: About to fall off read carefully, this is your focus.
35
+ - MIDDLE ZONE: Aging but visible. Note context that connects to evicting events.
36
+ - RECENT ZONE: Fresh. Use for continuity with your summary.
38
37
 
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.
38
+ Messages are prefixed with `message N` (database ID, used for pinning).
39
+ Tool calls are compressed to `[N tools called]` — focus on conversation, not mechanical work.
42
40
 
43
41
  ──────────────────────────────
44
- YOUR TASK
42
+ ACTIONS
45
43
  ──────────────────────────────
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.
44
+ Summarize evicting conversation with save_snapshot — capture what was discussed and decided,
45
+ why decisions were made, active goal progress, and context the agent will need later.
46
+ Paraphrase don't quote verbatim. Omit tool call details and mechanical steps.
47
+
48
+ Pin critical messages to goals with attach_messages_to_goals when exact wording matters
49
+ (user instructions, key corrections, key decisions). Pinned messages survive eviction
50
+ intact — use this sparingly for messages where paraphrasing would lose meaning.
51
+
52
+ If the eviction zone contains only mechanical activity, call everything_ok.
53
+
54
+ You may combine save_snapshot and attach_messages_to_goals in one turn.
73
55
  PROMPT
74
56
 
75
57
  # @param session [Session] the main session to observe
@@ -84,7 +66,7 @@ module Mneme
84
66
  end
85
67
 
86
68
  # Runs the Mneme loop: builds compressed viewport, calls LLM, executes
87
- # snapshot tool, then advances the terminal event pointer.
69
+ # snapshot tool, then advances the terminal message pointer.
88
70
  #
89
71
  # @return [String, nil] the LLM's final text response (discarded),
90
72
  # or nil if no context is available
@@ -94,18 +76,18 @@ module Mneme
94
76
  sid = @session.id
95
77
 
96
78
  if compressed_text.empty?
97
- log.debug("session=#{sid} — no events for Mneme, skipping")
79
+ log.debug("session=#{sid} — no messages for Mneme, skipping")
98
80
  return
99
81
  end
100
82
 
101
- messages = build_messages(compressed_text)
83
+ llm_messages = build_messages(compressed_text)
102
84
  system = SYSTEM_PROMPT
103
85
 
104
- log.info("session=#{sid} — running Mneme (#{viewport.events.size} events)")
86
+ log.info("session=#{sid} — running Mneme (#{viewport.messages.size} messages)")
105
87
  log.debug("compressed viewport:\n#{compressed_text}")
106
88
 
107
89
  result = @client.chat_with_tools(
108
- messages,
90
+ llm_messages,
109
91
  registry: build_registry(viewport),
110
92
  session_id: nil,
111
93
  system: system
@@ -118,7 +100,7 @@ module Mneme
118
100
 
119
101
  private
120
102
 
121
- # Builds the compressed viewport starting from the session's boundary event.
103
+ # Builds the compressed viewport starting from the session's boundary message.
122
104
  #
123
105
  # @return [Mneme::CompressedViewport]
124
106
  def build_compressed_viewport
@@ -127,7 +109,7 @@ module Mneme
127
109
  CompressedViewport.new(
128
110
  @session,
129
111
  token_budget: token_budget,
130
- from_event_id: @session.mneme_boundary_event_id
112
+ from_message_id: @session.mneme_boundary_message_id
131
113
  )
132
114
  end
133
115
 
@@ -150,59 +132,59 @@ module Mneme
150
132
  end
151
133
 
152
134
  # 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.
135
+ # Passes the message range from the viewport so the snapshot records
136
+ # which messages it covers.
155
137
  #
156
138
  # @param viewport [Mneme::CompressedViewport]
157
139
  # @return [Tools::Registry]
158
140
  def build_registry(viewport)
159
- viewport_events = viewport.events
141
+ viewport_messages = viewport.messages
160
142
  registry = ::Tools::Registry.new(context: {
161
143
  main_session: @session,
162
- from_event_id: viewport_events.first&.id,
163
- to_event_id: viewport_events.last&.id
144
+ from_message_id: viewport_messages.first&.id,
145
+ to_message_id: viewport_messages.last&.id
164
146
  })
165
147
  TOOLS.each { |tool| registry.register(tool) }
166
148
  registry
167
149
  end
168
150
 
169
- # Advances the terminal event pointer after Mneme completes.
151
+ # Advances the terminal message pointer after Mneme completes.
170
152
  # Runs unconditionally — even when the LLM called `everything_ok` (no snapshot
171
153
  # needed), the zone was reviewed and should be advanced past. Without this,
172
154
  # Mneme would re-examine the same mechanical-only content on every trigger.
173
155
  #
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.
156
+ # Sets it to the last conversation message in the viewport, ensuring
157
+ # the boundary is always a message/think message, never a tool_call/tool_response.
176
158
  # Also updates the snapshot range pointers.
177
159
  #
178
160
  # @param viewport [Mneme::CompressedViewport]
179
161
  def advance_boundary(viewport)
180
- viewport_events = viewport.events
181
- return if viewport_events.empty?
162
+ viewport_messages = viewport.messages
163
+ return if viewport_messages.empty?
182
164
 
183
- new_boundary = viewport_events.reverse_each.find { |event| conversation_or_think?(event) }
165
+ new_boundary = viewport_messages.reverse_each.find { |message| conversation_or_think?(message) }
184
166
  return unless new_boundary
185
167
 
186
168
  boundary_id = new_boundary.id
187
- updates = {mneme_boundary_event_id: boundary_id}
169
+ updates = {mneme_boundary_message_id: boundary_id}
188
170
 
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
171
+ updates[:mneme_snapshot_first_message_id] = viewport_messages.first.id unless @session.mneme_snapshot_first_message_id
172
+ updates[:mneme_snapshot_last_message_id] = viewport_messages.last.id
191
173
 
192
174
  @session.update_columns(updates)
193
- log.debug("session=#{@session.id} — boundary advanced to event #{boundary_id}")
175
+ log.debug("session=#{@session.id} — boundary advanced to message #{boundary_id}")
194
176
  end
195
177
 
196
- # Delegates to {Event#conversation_or_think?} — single source of truth
197
- # for which events Mneme treats as conversation boundaries.
178
+ # Delegates to {Message#conversation_or_think?} — single source of truth
179
+ # for which messages Mneme treats as conversation boundaries.
198
180
  #
199
181
  # @return [Boolean]
200
- def conversation_or_think?(event)
201
- event.conversation_or_think?
182
+ def conversation_or_think?(message)
183
+ message.conversation_or_think?
202
184
  end
203
185
 
204
186
  # Builds the active goals section for Mneme's context so it knows
205
- # what Goals exist, which events are already pinned, and can reference
187
+ # what Goals exist, which messages are already pinned, and can reference
206
188
  # them when deciding what to pin or summarize.
207
189
  #
208
190
  # @return [String] formatted goals section, or empty string
@@ -231,21 +213,21 @@ module Mneme
231
213
  parts.join("\n")
232
214
  end
233
215
 
234
- # Lists already-pinned event IDs so Mneme avoids redundant pinning.
216
+ # Lists already-pinned message IDs so Mneme avoids redundant pinning.
235
217
  #
236
218
  # @return [String, nil] formatted pin list, or nil when nothing is pinned
237
219
  def format_existing_pins
238
- pins = @session.pinned_events.includes(:goals).order(:event_id)
220
+ pins = @session.pinned_messages.includes(:goals).order(:message_id)
239
221
  return nil if pins.empty?
240
222
 
241
223
  pins.map { |pin| format_pin_for_mneme(pin) }.join("\n")
242
224
  end
243
225
 
244
- # @param pin [PinnedEvent] pin with preloaded goals
226
+ # @param pin [PinnedMessage] pin with preloaded goals
245
227
  # @return [String] formatted pin line
246
228
  def format_pin_for_mneme(pin)
247
229
  goal_ids = pin.goals.map(&:id).join(", ")
248
- " event #{pin.event_id} → goals [#{goal_ids}]"
230
+ " message #{pin.message_id} → goals [#{goal_ids}]"
249
231
  end
250
232
 
251
233
  # @return [Logger]
data/lib/mneme/search.rb CHANGED
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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.
4
+ # Full-text search over message history using SQLite FTS5.
5
+ # Covers user messages, agent messages, and think messages across all sessions.
6
6
  #
7
7
  # The interface is intentionally abstract — callers receive {Result} structs
8
8
  # and never touch FTS5 directly. A future semantic search backend (embeddings,
@@ -10,21 +10,21 @@ module Mneme
10
10
  #
11
11
  # @example Search across all sessions
12
12
  # results = Mneme::Search.query("authentication flow")
13
- # results.each { |r| puts "event #{r.event_id}: #{r.snippet}" }
13
+ # results.each { |r| puts "message #{r.message_id}: #{r.snippet}" }
14
14
  #
15
15
  # @example Search within a single session
16
16
  # results = Mneme::Search.query("OAuth config", session_id: 42)
17
17
  class Search
18
18
  # A single search result with enough context for display and drill-down.
19
19
  #
20
- # @!attribute event_id [Integer] the event's database ID
21
- # @!attribute session_id [Integer] the session owning this event
20
+ # @!attribute message_id [Integer] the message's database ID
21
+ # @!attribute session_id [Integer] the session owning this message
22
22
  # @!attribute snippet [String] highlighted excerpt from the matching content
23
23
  # @!attribute rank [Float] FTS5 relevance score (lower = more relevant)
24
- # @!attribute event_type [String] friendly label: human, anima, system, or thought
25
- Result = Struct.new(:event_id, :session_id, :snippet, :rank, :event_type, keyword_init: true)
24
+ # @!attribute message_type [String] friendly label: human, anima, system, or thought
25
+ Result = Struct.new(:message_id, :session_id, :snippet, :rank, :message_type, keyword_init: true)
26
26
 
27
- # Searches event history for the given terms.
27
+ # Searches message history for the given terms.
28
28
  #
29
29
  # @param terms [String] search query (FTS5 syntax: words, phrases, OR/AND/NOT)
30
30
  # @param session_id [Integer, nil] scope to a specific session (nil = all sessions)
@@ -52,7 +52,7 @@ module Mneme
52
52
  private
53
53
 
54
54
  # Executes the FTS5 MATCH query with optional session scoping.
55
- # Joins back to events table for session_id and event_type.
55
+ # Joins back to messages table for session_id and message_type.
56
56
  #
57
57
  # @return [Array<Hash>] raw database rows
58
58
  def execute_fts_query
@@ -66,29 +66,29 @@ module Mneme
66
66
  end
67
67
 
68
68
  # FTS5 query across all sessions.
69
- # Contentless FTS5 can't use snippet() — extract content from events directly.
69
+ # Contentless FTS5 can't use snippet() — extract content from messages directly.
70
70
  #
71
71
  # Ranking blends BM25 relevance with recency: rank is negative (more
72
- # negative = better match), so dividing by a factor > 1 for older events
72
+ # negative = better match), so dividing by a factor > 1 for older messages
73
73
  # moves them closer to zero (less relevant). At decay 0.3, a one-year-old
74
74
  # result needs ~30% better keyword relevance to beat an identical match
75
75
  # from today.
76
76
  def global_sql
77
77
  <<~SQL
78
78
  SELECT
79
- e.id AS event_id,
80
- e.session_id,
81
- e.event_type,
79
+ m.id AS message_id,
80
+ m.session_id,
81
+ m.message_type,
82
82
  CASE
83
- WHEN e.event_type IN ('user_message', 'agent_message', 'system_message')
84
- THEN substr(json_extract(e.payload, '$.content'), 1, 300)
85
- WHEN e.event_type = 'tool_call'
86
- THEN substr(json_extract(e.payload, '$.tool_input.thoughts'), 1, 300)
83
+ WHEN m.message_type IN ('user_message', 'agent_message', 'system_message')
84
+ THEN substr(json_extract(m.payload, '$.content'), 1, 300)
85
+ WHEN m.message_type = 'tool_call'
86
+ THEN substr(json_extract(m.payload, '$.tool_input.thoughts'), 1, 300)
87
87
  END AS snippet,
88
- rank / (1.0 + ? * (julianday('now') - julianday(e.created_at)) / 365.0) AS rank
89
- FROM events_fts
90
- JOIN events e ON e.id = events_fts.rowid
91
- WHERE events_fts MATCH ?
88
+ rank / (1.0 + ? * (julianday('now') - julianday(m.created_at)) / 365.0) AS rank
89
+ FROM messages_fts
90
+ JOIN messages m ON m.id = messages_fts.rowid
91
+ WHERE messages_fts MATCH ?
92
92
  ORDER BY rank
93
93
  LIMIT ?
94
94
  SQL
@@ -98,26 +98,26 @@ module Mneme
98
98
  def scoped_sql
99
99
  <<~SQL
100
100
  SELECT
101
- e.id AS event_id,
102
- e.session_id,
103
- e.event_type,
101
+ m.id AS message_id,
102
+ m.session_id,
103
+ m.message_type,
104
104
  CASE
105
- WHEN e.event_type IN ('user_message', 'agent_message', 'system_message')
106
- THEN substr(json_extract(e.payload, '$.content'), 1, 300)
107
- WHEN e.event_type = 'tool_call'
108
- THEN substr(json_extract(e.payload, '$.tool_input.thoughts'), 1, 300)
105
+ WHEN m.message_type IN ('user_message', 'agent_message', 'system_message')
106
+ THEN substr(json_extract(m.payload, '$.content'), 1, 300)
107
+ WHEN m.message_type = 'tool_call'
108
+ THEN substr(json_extract(m.payload, '$.tool_input.thoughts'), 1, 300)
109
109
  END AS snippet,
110
- rank / (1.0 + ? * (julianday('now') - julianday(e.created_at)) / 365.0) AS rank
111
- FROM events_fts
112
- JOIN events e ON e.id = events_fts.rowid
113
- WHERE events_fts MATCH ?
114
- AND e.session_id = ?
110
+ rank / (1.0 + ? * (julianday('now') - julianday(m.created_at)) / 365.0) AS rank
111
+ FROM messages_fts
112
+ JOIN messages m ON m.id = messages_fts.rowid
113
+ WHERE messages_fts MATCH ?
114
+ AND m.session_id = ?
115
115
  ORDER BY rank
116
116
  LIMIT ?
117
117
  SQL
118
118
  end
119
119
 
120
- FRIENDLY_EVENT_TYPES = {
120
+ FRIENDLY_MESSAGE_TYPES = {
121
121
  "user_message" => "human",
122
122
  "agent_message" => "anima",
123
123
  "system_message" => "system",
@@ -129,13 +129,13 @@ module Mneme
129
129
  # @param row [Hash]
130
130
  # @return [Result]
131
131
  def build_result(row)
132
- raw_type = row["event_type"]
132
+ raw_type = row["message_type"]
133
133
  Result.new(
134
- event_id: row["event_id"],
134
+ message_id: row["message_id"],
135
135
  session_id: row["session_id"],
136
136
  snippet: row["snippet"],
137
137
  rank: row["rank"],
138
- event_type: FRIENDLY_EVENT_TYPES.fetch(raw_type, raw_type)
138
+ message_type: FRIENDLY_MESSAGE_TYPES.fetch(raw_type, raw_type)
139
139
  )
140
140
  end
141
141
 
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ module Tools
5
+ # Pins critical messages to active Goals so they survive viewport eviction.
6
+ # Mneme calls this when it sees important messages (user instructions, key
7
+ # decisions, critical corrections) approaching the eviction zone.
8
+ #
9
+ # Messages are pinned via a many-to-many join: one message 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 AttachMessagesToGoals < ::Tools::Base
13
+ def self.tool_name = "attach_messages_to_goals"
14
+
15
+ def self.description = "Pin critical messages to goals so they survive viewport eviction."
16
+
17
+ def self.input_schema
18
+ {
19
+ type: "object",
20
+ properties: {
21
+ message_ids: {
22
+ type: "array",
23
+ items: {type: "integer"}
24
+ },
25
+ goal_ids: {
26
+ type: "array",
27
+ items: {type: "integer"}
28
+ }
29
+ },
30
+ required: %w[message_ids goal_ids]
31
+ }
32
+ end
33
+
34
+ # @param main_session [Session] the session being observed
35
+ def initialize(main_session:, **)
36
+ @session = main_session
37
+ end
38
+
39
+ # @param input [Hash<String, Object>] with "message_ids" and "goal_ids"
40
+ # @return [String] confirmation with link count, or error description
41
+ def execute(input)
42
+ message_ids = Array(input["message_ids"]).map(&:to_i).uniq
43
+ goal_ids = Array(input["goal_ids"]).map(&:to_i).uniq
44
+
45
+ return "Error: message_ids cannot be empty" if message_ids.empty?
46
+ return "Error: goal_ids cannot be empty" if goal_ids.empty?
47
+
48
+ messages = @session.messages.where(id: message_ids)
49
+ all_goals = @session.goals
50
+ goals = all_goals.active.where(id: goal_ids)
51
+
52
+ missing_messages = message_ids - messages.pluck(:id)
53
+ inactive_goal_ids = goal_ids - goals.pluck(:id)
54
+
55
+ errors = []
56
+ errors << "Messages not found: #{missing_messages.join(", ")}" if missing_messages.any?
57
+
58
+ if inactive_goal_ids.any?
59
+ completed_ids = all_goals.completed.where(id: inactive_goal_ids).pluck(:id)
60
+ not_found_ids = inactive_goal_ids - completed_ids
61
+ errors << "Goals already completed: #{completed_ids.join(", ")}" if completed_ids.any?
62
+ errors << "Goals not found: #{not_found_ids.join(", ")}" if not_found_ids.any?
63
+ end
64
+
65
+ return "Error: #{errors.join("; ")}" if errors.any?
66
+
67
+ attached = attach(messages, goals)
68
+ "Pinned #{attached} message-goal links"
69
+ end
70
+
71
+ private
72
+
73
+ def attach(messages, goals)
74
+ messages.sum do |message|
75
+ pinned = find_or_create_pinned_message(message)
76
+ link_to_goals(pinned, goals)
77
+ end
78
+ end
79
+
80
+ def link_to_goals(pinned, goals)
81
+ goals.each { |goal| GoalPinnedMessage.find_or_create_by!(goal: goal, pinned_message: pinned) }
82
+ goals.size
83
+ end
84
+
85
+ def find_or_create_pinned_message(message)
86
+ PinnedMessage.find_or_create_by!(message: message) do |pm|
87
+ pm.display_text = truncate_message_content(message)
88
+ end
89
+ end
90
+
91
+ def truncate_message_content(message)
92
+ content = message.payload&.dig("content").to_s.strip
93
+ content = "message #{message.id}" if content.empty?
94
+
95
+ if content.length > PinnedMessage::MAX_DISPLAY_TEXT_LENGTH
96
+ content[0, PinnedMessage::MAX_DISPLAY_TEXT_LENGTH - 1] + "…"
97
+ else
98
+ content
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -8,9 +8,7 @@ module Mneme
8
8
  class EverythingOk < ::Tools::Base
9
9
  def self.tool_name = "everything_ok"
10
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."
11
+ def self.description = "Nothing else worth remembering."
14
12
 
15
13
  def self.input_schema
16
14
  {type: "object", properties: {}, required: []}
@@ -12,10 +12,7 @@ module Mneme
12
12
  class SaveSnapshot < ::Tools::Base
13
13
  def self.tool_name = "save_snapshot"
14
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."
15
+ def self.description = "Summarize what's leaving the viewport."
19
16
 
20
17
  def self.input_schema
21
18
  {
@@ -23,8 +20,7 @@ module Mneme
23
20
  properties: {
24
21
  text: {
25
22
  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."
23
+ maxLength: Anima::Settings.mneme_max_tokens * Message::BYTES_PER_TOKEN
28
24
  }
29
25
  },
30
26
  required: %w[text]
@@ -32,13 +28,13 @@ module Mneme
32
28
  end
33
29
 
34
30
  # @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, **)
31
+ # @param from_message_id [Integer] first message ID covered by this snapshot
32
+ # @param to_message_id [Integer] last message ID covered by this snapshot
33
+ # @param level [Integer] compression level (1 = from messages, 2 = from L1 snapshots)
34
+ def initialize(main_session:, from_message_id:, to_message_id:, level: 1, **)
39
35
  @main_session = main_session
40
- @from_event_id = from_event_id
41
- @to_event_id = to_event_id
36
+ @from_message_id = from_message_id
37
+ @to_message_id = to_message_id
42
38
  @level = level
43
39
  end
44
40
 
@@ -48,20 +44,20 @@ module Mneme
48
44
 
49
45
  snapshot = @main_session.snapshots.create!(
50
46
  text: text,
51
- from_event_id: @from_event_id,
52
- to_event_id: @to_event_id,
47
+ from_message_id: @from_message_id,
48
+ to_message_id: @to_message_id,
53
49
  level: @level,
54
50
  token_count: estimate_tokens(text)
55
51
  )
56
52
 
57
- "Snapshot saved (id: #{snapshot.id}, events #{@from_event_id}..#{@to_event_id})"
53
+ "Snapshot saved (id: #{snapshot.id}, messages #{@from_message_id}..#{@to_message_id})"
58
54
  end
59
55
 
60
56
  private
61
57
 
62
58
  # @return [Integer] estimated token count for the summary text
63
59
  def estimate_tokens(text)
64
- [(text.bytesize / Event::BYTES_PER_TOKEN.to_f).ceil, 1].max
60
+ [(text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
65
61
  end
66
62
  end
67
63
  end