anima-core 1.3.0 → 1.5.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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +23 -26
  3. data/README.md +118 -104
  4. data/agents/thoughts-analyzer.md +12 -7
  5. data/anima-core.gemspec +1 -0
  6. data/app/channels/session_channel.rb +38 -58
  7. data/app/decorators/agent_message_decorator.rb +7 -2
  8. data/app/decorators/message_decorator.rb +31 -100
  9. data/app/decorators/pending_from_melete_decorator.rb +36 -0
  10. data/app/decorators/pending_from_melete_goal_decorator.rb +13 -0
  11. data/app/decorators/pending_from_melete_skill_decorator.rb +19 -0
  12. data/app/decorators/pending_from_melete_workflow_decorator.rb +13 -0
  13. data/app/decorators/pending_from_mneme_decorator.rb +44 -0
  14. data/app/decorators/pending_message_decorator.rb +94 -0
  15. data/app/decorators/pending_subagent_decorator.rb +46 -0
  16. data/app/decorators/pending_tool_response_decorator.rb +51 -0
  17. data/app/decorators/pending_user_message_decorator.rb +22 -0
  18. data/app/decorators/system_message_decorator.rb +5 -0
  19. data/app/decorators/tool_call_decorator.rb +16 -5
  20. data/app/decorators/tool_response_decorator.rb +2 -2
  21. data/app/decorators/user_message_decorator.rb +7 -2
  22. data/app/jobs/count_tokens_job.rb +23 -0
  23. data/app/jobs/drain_job.rb +169 -0
  24. data/app/jobs/melete_enrichment_job/goal_change_listener.rb +52 -0
  25. data/app/jobs/melete_enrichment_job.rb +48 -0
  26. data/app/jobs/mneme_enrichment_job.rb +46 -0
  27. data/app/jobs/tool_execution_job.rb +87 -0
  28. data/app/models/concerns/token_estimation.rb +54 -0
  29. data/app/models/goal.rb +23 -11
  30. data/app/models/message.rb +46 -48
  31. data/app/models/pending_message.rb +407 -12
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +660 -566
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +157 -0
  36. data/bin/release +212 -0
  37. data/bin/with-llms +20 -0
  38. data/config/application.rb +1 -0
  39. data/config/database.yml +1 -0
  40. data/config/initializers/event_subscribers.rb +71 -4
  41. data/config/initializers/inflections.rb +3 -1
  42. data/db/cable_structure.sql +9 -0
  43. data/db/migrate/20260330120000_add_source_to_pending_messages.rb +8 -0
  44. data/db/migrate/20260401180000_add_api_metrics_to_messages.rb +7 -0
  45. data/db/migrate/20260401210935_remove_recalled_message_ids_from_sessions.rb +5 -0
  46. data/db/migrate/20260403080031_add_initial_cwd_to_sessions.rb +5 -0
  47. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  48. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  49. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  50. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  51. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  52. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  53. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  54. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  55. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  56. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  57. data/db/queue_structure.sql +61 -0
  58. data/db/structure.sql +133 -0
  59. data/lib/agents/registry.rb +1 -1
  60. data/lib/anima/cli.rb +41 -13
  61. data/lib/anima/installer.rb +13 -0
  62. data/lib/anima/settings.rb +16 -36
  63. data/lib/anima/version.rb +1 -1
  64. data/lib/events/authentication_required.rb +24 -0
  65. data/lib/events/bounce_back.rb +4 -4
  66. data/lib/events/eviction_completed.rb +28 -0
  67. data/lib/events/goal_created.rb +28 -0
  68. data/lib/events/goal_updated.rb +32 -0
  69. data/lib/events/llm_responded.rb +35 -0
  70. data/lib/events/message_created.rb +27 -0
  71. data/lib/events/message_updated.rb +25 -0
  72. data/lib/events/session_state_changed.rb +30 -0
  73. data/lib/events/skill_activated.rb +28 -0
  74. data/lib/events/start_melete.rb +36 -0
  75. data/lib/events/start_mneme.rb +33 -0
  76. data/lib/events/start_processing.rb +32 -0
  77. data/lib/events/subagent_evicted.rb +31 -0
  78. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  79. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  80. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  81. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  82. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  83. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  84. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  85. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  86. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  87. data/lib/events/subscribers/persister.rb +8 -9
  88. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  89. data/lib/events/subscribers/subagent_message_router.rb +28 -34
  90. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  91. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  92. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  93. data/lib/events/tool_executed.rb +34 -0
  94. data/lib/events/workflow_activated.rb +27 -0
  95. data/lib/llm/client.rb +46 -199
  96. data/lib/mcp/client_manager.rb +41 -46
  97. data/lib/mcp/stdio_transport.rb +9 -5
  98. data/lib/{analytical_brain → melete}/runner.rb +73 -68
  99. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +3 -3
  100. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +3 -3
  101. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  102. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +6 -3
  103. data/lib/melete/tools/goal_messaging.rb +29 -0
  104. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +4 -4
  105. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  106. data/lib/{analytical_brain → melete}/tools/set_goal.rb +6 -2
  107. data/lib/{analytical_brain → melete}/tools/update_goal.rb +9 -5
  108. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  109. data/lib/mneme/base_runner.rb +121 -0
  110. data/lib/mneme/l2_runner.rb +14 -20
  111. data/lib/mneme/recall_runner.rb +132 -0
  112. data/lib/mneme/runner.rb +123 -165
  113. data/lib/mneme/search.rb +104 -62
  114. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  115. data/lib/mneme/tools/save_snapshot.rb +2 -10
  116. data/lib/mneme/tools/surface_memory.rb +89 -0
  117. data/lib/mneme.rb +11 -5
  118. data/lib/providers/anthropic.rb +112 -7
  119. data/lib/shell_session.rb +290 -432
  120. data/lib/skills/definition.rb +2 -2
  121. data/lib/skills/registry.rb +1 -1
  122. data/lib/tools/base.rb +16 -1
  123. data/lib/tools/bash.rb +25 -55
  124. data/lib/tools/edit.rb +2 -0
  125. data/lib/tools/mark_goal_completed.rb +4 -5
  126. data/lib/tools/read.rb +2 -0
  127. data/lib/tools/registry.rb +85 -4
  128. data/lib/tools/response_truncator.rb +1 -1
  129. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  130. data/lib/tools/spawn_specialist.rb +22 -14
  131. data/lib/tools/spawn_subagent.rb +30 -20
  132. data/lib/tools/subagent_prompts.rb +17 -19
  133. data/lib/tools/think.rb +1 -1
  134. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  135. data/lib/tools/write.rb +2 -0
  136. data/lib/tui/app.rb +393 -149
  137. data/lib/tui/braille_spinner.rb +7 -7
  138. data/lib/tui/cable_client.rb +9 -16
  139. data/lib/tui/decorators/base_decorator.rb +47 -6
  140. data/lib/tui/decorators/bash_decorator.rb +1 -1
  141. data/lib/tui/decorators/edit_decorator.rb +4 -2
  142. data/lib/tui/decorators/read_decorator.rb +4 -2
  143. data/lib/tui/decorators/think_decorator.rb +2 -2
  144. data/lib/tui/decorators/web_get_decorator.rb +1 -1
  145. data/lib/tui/decorators/write_decorator.rb +4 -2
  146. data/lib/tui/flash.rb +19 -14
  147. data/lib/tui/formatting.rb +20 -9
  148. data/lib/tui/input_buffer.rb +6 -6
  149. data/lib/tui/message_store.rb +165 -28
  150. data/lib/tui/performance_logger.rb +2 -3
  151. data/lib/tui/screens/chat.rb +149 -79
  152. data/lib/tui/settings.rb +93 -0
  153. data/lib/workflows/definition.rb +3 -3
  154. data/lib/workflows/registry.rb +1 -1
  155. data/skills/github.md +38 -0
  156. data/templates/config.toml +16 -32
  157. data/templates/tui.toml +209 -0
  158. data/workflows/review_pr.md +18 -14
  159. metadata +98 -29
  160. data/app/jobs/agent_request_job.rb +0 -199
  161. data/app/jobs/analytical_brain_job.rb +0 -33
  162. data/app/jobs/count_message_tokens_job.rb +0 -39
  163. data/app/jobs/passive_recall_job.rb +0 -29
  164. data/app/models/concerns/message/broadcasting.rb +0 -85
  165. data/config/initializers/fts5_schema_dump.rb +0 -21
  166. data/lib/agent_loop.rb +0 -186
  167. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -39
  168. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -34
  169. data/lib/environment_probe.rb +0 -232
  170. data/lib/events/agent_message.rb +0 -11
  171. data/lib/events/subscribers/message_collector.rb +0 -64
  172. data/lib/events/tool_call.rb +0 -31
  173. data/lib/events/tool_response.rb +0 -33
  174. data/lib/mneme/compressed_viewport.rb +0 -200
  175. data/lib/mneme/passive_recall.rb +0 -69
data/lib/mneme/runner.rb CHANGED
@@ -1,198 +1,170 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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 message (`mneme_boundary_message_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 message to the boundary of what
13
- # it just summarized, so the cycle repeats as more messages accumulate.
4
+ # Mneme in eviction mode — a phantom LLM loop that summarizes the oldest
5
+ # slice of the viewport before it slides off. She sees the eviction zone
6
+ # (what she's compressing) plus the remaining viewport (context she needs
7
+ # to write a faithful summary), calls {Tools::SaveSnapshot} to persist
8
+ # the compressed memory, optionally pins critical messages to goals, then
9
+ # advances the Mneme boundary past the zone so the cycle repeats as more
10
+ # messages accumulate.
14
11
  #
15
12
  # @example
16
13
  # Mneme::Runner.new(session).call
17
- class Runner
14
+ class Runner < BaseRunner
18
15
  TOOLS = [
19
16
  Tools::SaveSnapshot,
20
17
  Tools::AttachMessagesToGoals,
21
18
  Tools::EverythingOk
22
19
  ].freeze
23
20
 
24
- SYSTEM_PROMPT = <<~PROMPT
25
- You are Mneme, the memory department of an AI agent named Anima.
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.
21
+ TASK_PROMPT = <<~PROMPT
22
+ Right now your work is compression. As Aoide's viewport slides forward, you catch what's about to fall off and turn it into something she can carry.
29
23
 
30
24
  ──────────────────────────────
31
- VIEWPORT
25
+ WHAT YOU SEE
32
26
  ──────────────────────────────
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.
27
+ Two sections of the viewport, oldest to newest:
28
+ - EVICTION ZONE: about to fall off. This is what you summarize.
29
+ - CONTEXT: the live viewport past the eviction zone. Use it for continuity Aoide is still seeing it.
37
30
 
38
31
  Messages are prefixed with `message N` (database ID, used for pinning).
39
32
  Tool calls are compressed to `[N tools called]` — focus on conversation, not mechanical work.
40
33
 
41
34
  ──────────────────────────────
42
- ACTIONS
35
+ HOW TO REMEMBER
43
36
  ──────────────────────────────
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.
37
+ Summarize the eviction zone with save_snapshot: what was discussed and decided, why, goal progress, and the context Aoide will need later. Paraphrase — don't quote verbatim. Drop mechanical steps.
47
38
 
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.
39
+ A snapshot is a tax on Aoide's viewport budget. Every word you write takes a word she can't spend on the current exchange. Capture the load-bearing details; let the rest go.
51
40
 
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.
55
- PROMPT
41
+ Pin critical messages to goals with attach_messages_to_goals when exact wording matters — user instructions, key corrections, key decisions. A pinned message survives eviction intact. Use it sparingly: each pin is another slice of viewport Aoide carries forward.
56
42
 
57
- # @param session [Session] the main session to observe
58
- # @param client [LLM::Client, nil] injectable LLM client (defaults to fast model)
59
- def initialize(session, client: nil)
60
- @session = session
61
- @client = client || LLM::Client.new(
62
- model: Anima::Settings.fast_model,
63
- max_tokens: Anima::Settings.mneme_max_tokens,
64
- logger: Mneme.logger
65
- )
66
- end
67
-
68
- # Runs the Mneme loop: builds compressed viewport, calls LLM, executes
69
- # snapshot tool, then advances the terminal message pointer.
70
- #
71
- # @return [String, nil] the LLM's final text response (discarded),
72
- # or nil if no context is available
73
- def call
74
- viewport = build_compressed_viewport
75
- compressed_text = viewport.render
76
- sid = @session.id
77
-
78
- if compressed_text.empty?
79
- log.debug("session=#{sid} — no messages for Mneme, skipping")
80
- return
81
- end
43
+ If the eviction zone holds only mechanical activity — tool calls, no conversation — call everything_ok and let it fall off without a snapshot.
82
44
 
83
- llm_messages = build_messages(compressed_text)
84
- system = SYSTEM_PROMPT
85
-
86
- log.info("session=#{sid} — running Mneme (#{viewport.messages.size} messages)")
87
- log.debug("compressed viewport:\n#{compressed_text}")
88
-
89
- result = @client.chat_with_tools(
90
- llm_messages,
91
- registry: build_registry(viewport),
92
- session_id: nil,
93
- system: system
94
- )
95
-
96
- advance_boundary(viewport)
97
- log.info("session=#{sid} — Mneme done: #{result.to_s.truncate(200)}")
98
- result
99
- end
45
+ save_snapshot and attach_messages_to_goals can be called together in one turn.
46
+ PROMPT
100
47
 
101
48
  private
102
49
 
103
- # Builds the compressed viewport starting from the session's boundary message.
104
- #
105
- # @return [Mneme::CompressedViewport]
106
- def build_compressed_viewport
107
- token_budget = (Anima::Settings.token_budget * Anima::Settings.mneme_viewport_fraction).to_i
108
-
109
- CompressedViewport.new(
110
- @session,
111
- token_budget: token_budget,
112
- from_message_id: @session.mneme_boundary_message_id
113
- )
114
- end
50
+ def task_prompt = TASK_PROMPT
115
51
 
116
- # Frames the compressed viewport as a user message for the LLM.
117
- #
118
- # @param compressed_text [String] the rendered compressed viewport
119
- # @return [Array<Hash>] single-element messages array
120
- def build_messages(compressed_text)
121
- goals_context = active_goals_section
52
+ def user_messages
53
+ eviction = @eviction ||= session.eviction_zone_messages
54
+ context = @context ||= session.viewport_messages.where("messages.id > ?", eviction.last.id)
55
+ transcript = render_transcript(eviction, context)
56
+ goals = active_goals_section
57
+
58
+ log.info("session=#{session.id} — eviction (#{eviction.size} eviction + #{context.size} context)")
59
+ log.debug("compressed viewport:\n#{transcript}")
122
60
 
123
61
  content = <<~MSG.strip
124
- Here is the compressed viewport of the main session:
62
+ Here is Aoide's viewport:
125
63
 
126
- #{compressed_text}
127
- #{goals_context}
128
- Review the eviction zone and decide whether to save a snapshot or signal everything_ok.
64
+ #{transcript}
65
+ #{goals}
66
+ Review the eviction zone and summarize it with save_snapshot.
67
+ If the zone holds only mechanical activity, call everything_ok.
129
68
  MSG
130
69
 
131
70
  [{role: "user", content: content}]
132
71
  end
133
72
 
134
- # Builds the tool registry with session context for SaveSnapshot.
135
- # Passes the message range from the viewport so the snapshot records
136
- # which messages it covers.
137
- #
138
- # @param viewport [Mneme::CompressedViewport]
139
- # @return [Tools::Registry]
140
- def build_registry(viewport)
141
- viewport_messages = viewport.messages
73
+ def build_registry
74
+ eviction = @eviction ||= session.eviction_zone_messages
142
75
  registry = ::Tools::Registry.new(context: {
143
- main_session: @session,
144
- from_message_id: viewport_messages.first&.id,
145
- to_message_id: viewport_messages.last&.id
76
+ main_session: session,
77
+ from_message_id: session.mneme_boundary_message_id,
78
+ to_message_id: eviction.last.id
146
79
  })
147
80
  TOOLS.each { |tool| registry.register(tool) }
148
81
  registry
149
82
  end
150
83
 
151
- # Advances the terminal message pointer after Mneme completes.
152
- # Runs unconditionally even when the LLM called `everything_ok` (no snapshot
153
- # needed), the zone was reviewed and should be advanced past. Without this,
154
- # Mneme would re-examine the same mechanical-only content on every trigger.
155
- #
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.
158
- # Also updates the snapshot range pointers.
159
- #
160
- # @param viewport [Mneme::CompressedViewport]
161
- def advance_boundary(viewport)
162
- viewport_messages = viewport.messages
163
- return if viewport_messages.empty?
164
-
165
- new_boundary = viewport_messages.reverse_each.find { |message| conversation_or_think?(message) }
166
- return unless new_boundary
167
-
168
- boundary_id = new_boundary.id
169
- updates = {mneme_boundary_message_id: boundary_id}
170
-
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
173
-
174
- @session.update_columns(updates)
175
- log.debug("session=#{@session.id} — boundary advanced to message #{boundary_id}")
84
+ def after_call(_result)
85
+ eviction = @eviction or return
86
+ last_evicted_id = eviction.last.id
87
+
88
+ new_boundary_id = session.messages
89
+ .conversation_or_think
90
+ .where("id > ?", last_evicted_id)
91
+ .order(:id)
92
+ .pick(:id) || last_evicted_id
93
+
94
+ session.update_column(:mneme_boundary_message_id, new_boundary_id)
95
+ Events::Bus.emit(Events::EvictionCompleted.new(
96
+ session_id: session.id,
97
+ evict_above_id: last_evicted_id
98
+ ))
99
+ refresh_subagent_visibility
100
+ log.debug("session=#{session.id} — boundary advanced to message #{new_boundary_id}")
101
+ end
102
+
103
+ # Flips visible sub-agents to +hud_visible: false+ once every one of
104
+ # their viewport traces (spawn pair + +from_{nickname}+ phantom pairs)
105
+ # has fallen past the Mneme boundary. Emits {Events::SubagentEvicted}
106
+ # per flip so the broadcaster removes them from the HUD panel on the
107
+ # parent stream. Flips are logged so the transition is auditable.
108
+ def refresh_subagent_visibility
109
+ session_id = session.id
110
+ session.child_sessions.where(hud_visible: true).each do |child|
111
+ next if session.subagent_trace_in_viewport?(child)
112
+
113
+ child_id = child.id
114
+ child.update_column(:hud_visible, false)
115
+ Events::Bus.emit(Events::SubagentEvicted.new(session_id: session_id, child_id: child_id))
116
+ log.debug("session=#{session_id} — sub-agent #{child_id} evicted from HUD")
117
+ end
176
118
  end
177
119
 
178
- # Delegates to {Message#conversation_or_think?} single source of truth
179
- # for which messages Mneme treats as conversation boundaries.
180
- #
181
- # @return [Boolean]
182
- def conversation_or_think?(message)
183
- message.conversation_or_think?
120
+ # Renders eviction zone and context as a Mneme transcript using
121
+ # message decorators. Tool calls are compressed into counters.
122
+ def render_transcript(eviction, context)
123
+ [
124
+ "── EVICTION ZONE ──",
125
+ render_messages(eviction),
126
+ "── CONTEXT ──",
127
+ render_messages(context)
128
+ ].join("\n")
184
129
  end
185
130
 
186
- # Builds the active goals section for Mneme's context so it knows
187
- # what Goals exist, which messages are already pinned, and can reference
188
- # them when deciding what to pin or summarize.
189
- #
190
- # @return [String] formatted goals section, or empty string
131
+ # Renders messages using decorators, compressing consecutive
132
+ # tool calls into `[N tools called]` counters.
133
+ def render_messages(messages)
134
+ lines = []
135
+ tool_count = 0
136
+
137
+ messages.each do |message|
138
+ rendered = message.decorate.render("mneme")
139
+
140
+ case rendered
141
+ when :tool_call
142
+ tool_count += 1
143
+ when nil
144
+ next
145
+ else
146
+ lines << flush_tool_count(tool_count) if tool_count > 0
147
+ tool_count = 0
148
+ lines << rendered
149
+ end
150
+ end
151
+
152
+ lines << flush_tool_count(tool_count) if tool_count > 0
153
+ lines.join("\n")
154
+ end
155
+
156
+ def flush_tool_count(count)
157
+ "[#{count} #{(count == 1) ? "tool" : "tools"} called]"
158
+ end
159
+
160
+ # Active-goals block so Mneme knows what Goals exist, which messages
161
+ # are already pinned, and can reference them when deciding what to
162
+ # pin or summarize.
191
163
  def active_goals_section
192
- root_goals = @session.goals.root.includes(:sub_goals).active.order(:created_at)
164
+ root_goals = session.goals.root.includes(:sub_goals).active.order(:created_at)
193
165
  return "" if root_goals.empty?
194
166
 
195
- lines = root_goals.map { |goal| format_goal_for_mneme(goal) }
167
+ lines = root_goals.map { |goal| format_goal(goal) }
196
168
  pinned = format_existing_pins
197
169
 
198
170
  section = "\n\n🎯 Active Goals\n#{lines.join("\n")}\n"
@@ -200,11 +172,7 @@ module Mneme
200
172
  section
201
173
  end
202
174
 
203
- # Formats a goal with sub-goals for Mneme's context.
204
- #
205
- # @param goal [Goal] root goal with preloaded sub_goals
206
- # @return [String]
207
- def format_goal_for_mneme(goal)
175
+ def format_goal(goal)
208
176
  parts = [" ● #{goal.description} (id: #{goal.id})"]
209
177
  goal.sub_goals.each do |sub|
210
178
  checkbox = sub.completed? ? "[x]" : "[ ]"
@@ -213,24 +181,14 @@ module Mneme
213
181
  parts.join("\n")
214
182
  end
215
183
 
216
- # Lists already-pinned message IDs so Mneme avoids redundant pinning.
217
- #
218
- # @return [String, nil] formatted pin list, or nil when nothing is pinned
219
184
  def format_existing_pins
220
- pins = @session.pinned_messages.includes(:goals).order(:message_id)
185
+ pins = session.pinned_messages.includes(:goals).order(:message_id)
221
186
  return nil if pins.empty?
222
187
 
223
- pins.map { |pin| format_pin_for_mneme(pin) }.join("\n")
188
+ pins.map { |pin|
189
+ goal_ids = pin.goals.map(&:id).join(", ")
190
+ " message #{pin.message_id} → goals [#{goal_ids}]"
191
+ }.join("\n")
224
192
  end
225
-
226
- # @param pin [PinnedMessage] pin with preloaded goals
227
- # @return [String] formatted pin line
228
- def format_pin_for_mneme(pin)
229
- goal_ids = pin.goals.map(&:id).join(", ")
230
- " message #{pin.message_id} → goals [#{goal_ids}]"
231
- end
232
-
233
- # @return [Logger]
234
- def log = Mneme.logger
235
193
  end
236
194
  end
data/lib/mneme/search.rb CHANGED
@@ -1,19 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Mneme
4
- # Full-text search over message history using SQLite FTS5.
5
- # Covers user messages, agent messages, and think messages across all sessions.
4
+ # Full-text search over long-term memory — the message history outside
5
+ # the caller's current viewport. Covers user messages, agent messages,
6
+ # and think messages across every session Anima has ever held.
6
7
  #
7
8
  # The interface is intentionally abstract — callers receive {Result} structs
8
9
  # and never touch FTS5 directly. A future semantic search backend (embeddings,
9
10
  # BM25 + re-ranking) can replace the implementation without changing callers.
10
11
  #
11
- # @example Search across all sessions
12
- # results = Mneme::Search.query("authentication flow")
13
- # results.each { |r| puts "message #{r.message_id}: #{r.snippet}" }
12
+ # @example Mneme's recall muse searching for the main session
13
+ # Mneme::Search.query("authentication flow", caller_session: session)
14
14
  #
15
- # @example Search within a single session
16
- # results = Mneme::Search.query("OAuth config", session_id: 42)
15
+ # @example Aoide searching actively from her own session
16
+ # Mneme::Search.query("OAuth config", caller_session: session)
17
17
  class Search
18
18
  # A single search result with enough context for display and drill-down.
19
19
  #
@@ -24,19 +24,27 @@ module Mneme
24
24
  # @!attribute message_type [String] friendly label: human, anima, system, or thought
25
25
  Result = Struct.new(:message_id, :session_id, :snippet, :rank, :message_type, keyword_init: true)
26
26
 
27
- # Searches message history for the given terms.
27
+ # Searches long-term memory for the given terms.
28
+ #
29
+ # Excludes messages currently in the caller's viewport so a `LIMIT`-bounded
30
+ # search never burns its slots returning things the caller already has in
31
+ # front of them. A caller with no established Mneme boundary yet (fresh
32
+ # main session, sub-agent) treats the whole session as "in viewport" — none
33
+ # of its own messages surface.
28
34
  #
29
35
  # @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)
36
+ # @param caller_session [Session] the session doing the search used to
37
+ # exclude its own viewport from the results. Required; search always
38
+ # happens from the perspective of a specific session.
31
39
  # @param limit [Integer] maximum results
32
40
  # @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
41
+ def self.query(terms, caller_session:, limit: Anima::Settings.recall_max_results)
42
+ new(terms, caller_session: caller_session, limit: limit).call
35
43
  end
36
44
 
37
- def initialize(terms, session_id: nil, limit: 5)
45
+ def initialize(terms, caller_session:, limit: 5)
38
46
  @terms = sanitize_query(terms)
39
- @session_id = session_id
47
+ @caller_session = caller_session
40
48
  @limit = limit
41
49
  @recency_decay = Anima::Settings.recall_recency_decay
42
50
  end
@@ -51,30 +59,29 @@ module Mneme
51
59
 
52
60
  private
53
61
 
54
- # Executes the FTS5 MATCH query with optional session scoping.
55
- # Joins back to messages table for session_id and message_type.
62
+ # Executes the FTS5 MATCH query with viewport exclusion for the caller.
56
63
  #
57
64
  # @return [Array<Hash>] raw database rows
58
65
  def execute_fts_query
59
- sql = if @session_id
60
- Arel.sql(scoped_sql, @recency_decay, @terms, @session_id, @limit)
61
- else
62
- Arel.sql(global_sql, @recency_decay, @terms, @limit)
63
- end
64
-
65
- connection.select_all(sql, "Mneme::Search").to_a
66
+ sql, binds = build_sql_and_binds
67
+ connection.select_all(Arel.sql(sql, *binds), "Mneme::Search").to_a
66
68
  end
67
69
 
68
- # FTS5 query across all sessions.
69
- # Contentless FTS5 can't use snippet() — extract content from messages directly.
70
- #
71
- # Ranking blends BM25 relevance with recency: rank is negative (more
72
- # negative = better match), so dividing by a factor > 1 for older messages
73
- # moves them closer to zero (less relevant). At decay 0.3, a one-year-old
74
- # result needs ~30% better keyword relevance to beat an identical match
75
- # from today.
76
- def global_sql
77
- <<~SQL
70
+ # Builds the FTS5 SQL. Viewport exclusion depends on whether the caller's
71
+ # session has a Mneme boundary:
72
+ # * boundary set → exclude caller's messages at or above it (they're visible).
73
+ # * boundary nil exclude the caller's whole session (no eviction has
74
+ # happened yet, so everything is visible).
75
+ # Other sessions are always unfiltered their IDs and boundaries mean
76
+ # nothing to the caller's context.
77
+ def build_sql_and_binds
78
+ binds = [@recency_decay, @terms]
79
+
80
+ viewport_clause, viewport_binds = caller_viewport_exclusion
81
+ binds.concat(viewport_binds)
82
+ binds << @limit
83
+
84
+ sql = <<~SQL
78
85
  SELECT
79
86
  m.id AS message_id,
80
87
  m.session_id,
@@ -89,32 +96,22 @@ module Mneme
89
96
  FROM messages_fts
90
97
  JOIN messages m ON m.id = messages_fts.rowid
91
98
  WHERE messages_fts MATCH ?
99
+ AND #{viewport_clause}
92
100
  ORDER BY rank
93
101
  LIMIT ?
94
102
  SQL
103
+
104
+ [sql, binds]
95
105
  end
96
106
 
97
- # FTS5 query scoped to a specific session.
98
- def scoped_sql
99
- <<~SQL
100
- SELECT
101
- m.id AS message_id,
102
- m.session_id,
103
- m.message_type,
104
- CASE
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
- END AS snippet,
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
- ORDER BY rank
116
- LIMIT ?
117
- SQL
107
+ # Returns the SQL fragment + bind params that exclude the caller's viewport.
108
+ def caller_viewport_exclusion
109
+ boundary = @caller_session.mneme_boundary_message_id
110
+ if boundary
111
+ ["(m.session_id != ? OR m.id < ?)", [@caller_session.id, boundary]]
112
+ else
113
+ ["m.session_id != ?", [@caller_session.id]]
114
+ end
118
115
  end
119
116
 
120
117
  FRIENDLY_MESSAGE_TYPES = {
@@ -139,25 +136,70 @@ module Mneme
139
136
  )
140
137
  end
141
138
 
142
- # Sanitizes user input for FTS5 MATCH safety.
143
- # Strips special FTS5 operators that could cause syntax errors,
144
- # keeps only alphanumeric words and quoted phrases.
139
+ # FTS5 logical-operator keywords callers may pass verbatim. Everything
140
+ # else is quote-wrapped, which is SQLite's recommended way to feed
141
+ # untrusted text to +MATCH+ inside a quoted phrase the tokenizer
142
+ # treats +- : * ^ { } ( )+ and any future operator character as
143
+ # ordinary content, so hazards like +sub-agents+ (parsed as
144
+ # +sub NOT agents+ → +no such column: agents+) and +agents:foo+
145
+ # (parsed as a column filter) become literal phrases.
146
+ #
147
+ # Adding +NEAR+ or any new operator that a caller legitimately needs
148
+ # is a one-line change here; character-level blocklists would need to
149
+ # be re-audited against every FTS5 release.
150
+ #
151
+ # @see https://www.sqlite.org/fts5.html FTS5 query syntax
152
+ # @see https://www.mail-archive.com/sqlite-users@mailinglists.sqlite.org/msg118320.html
153
+ # Dan Kennedy's canonical guidance on MATCH sanitization
154
+ FTS5_PASSTHROUGH_OPERATORS = Set.new(%w[AND OR NOT NEAR]).freeze
155
+ private_constant :FTS5_PASSTHROUGH_OPERATORS
156
+
157
+ # Sanitizes user input for FTS5 MATCH safety by quote-wrapping each
158
+ # token. Logical operators ({FTS5_PASSTHROUGH_OPERATORS}) pass through
159
+ # so callers that intentionally build +word1 OR word2+ queries still
160
+ # get boolean behavior.
161
+ #
162
+ # A query that collapses to operators only (e.g. user typed "and or
163
+ # not") has no operands and would trigger an FTS5 syntax error, so we
164
+ # return an empty string and let {#call} short-circuit via
165
+ # +@terms.blank?+.
145
166
  #
146
167
  # @param raw [String]
147
168
  # @return [String] safe FTS5 query
148
169
  def sanitize_query(raw)
149
170
  return "" unless raw
150
171
 
151
- # Extract quoted phrases and individual words, drop FTS5 operators
152
- tokens = raw.scan(/"[^"]+?"|\S+/).reject { |token| token.match?(/\A[*:^{}()]+\z/) }
153
- tokens.filter_map { |token| sanitize_token(token) }.join(" ")
172
+ tokens = raw.scan(/"[^"]*"|\S+/).filter_map { |token| sanitize_token(token) }
173
+ return "" if tokens.all? { |t| FTS5_PASSTHROUGH_OPERATORS.include?(t) }
174
+
175
+ tokens.join(" ")
154
176
  end
155
177
 
178
+ # @param token [String] one whitespace-delimited chunk of user input
179
+ # @return [String, nil] nil when the token is empty after cleanup
156
180
  def sanitize_token(token)
157
- return token if token.start_with?('"')
181
+ return token if FTS5_PASSTHROUGH_OPERATORS.include?(token)
182
+ return rewrap_phrase(token) if token.start_with?('"')
183
+
184
+ quote_as_phrase(token)
185
+ end
186
+
187
+ # Rebalances a user-supplied phrase so a stray or doubled quote can't
188
+ # leave the overall query syntactically broken.
189
+ #
190
+ # @param token [String] token starting with +"+
191
+ # @return [String, nil]
192
+ def rewrap_phrase(token)
193
+ inner = token.delete_prefix('"').delete_suffix('"').strip
194
+ inner.empty? ? nil : quote_as_phrase(inner)
195
+ end
196
+
197
+ # @param text [String] any token treated as literal content
198
+ # @return [String, nil]
199
+ def quote_as_phrase(text)
200
+ return nil if text.empty?
158
201
 
159
- cleaned = token.gsub(/[^a-zA-Z0-9-]/, "")
160
- cleaned.empty? ? nil : cleaned
202
+ %("#{text.gsub('"', '""')}")
161
203
  end
162
204
 
163
205
  def connection
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ module Tools
5
+ # Finish-line tool for {Mneme::RecallRunner}. The muse calls this when
6
+ # she's done her work — whether she surfaced memories or decided
7
+ # nothing was worth carrying forward. Having a single finish line makes
8
+ # every recall run explicit: silence is intentional, not a timeout.
9
+ #
10
+ # Mirror of {EverythingOk} for the eviction runner.
11
+ class NothingToSurface < ::Tools::Base
12
+ def self.tool_name = "nothing_to_surface"
13
+
14
+ def self.description = "Finish the recall run. Call this when you're done — whether you surfaced memories or decided nothing was worth surfacing right now. Silence is a valid answer when older memory wouldn't help."
15
+
16
+ def self.input_schema
17
+ {type: "object", properties: {}, required: []}
18
+ end
19
+
20
+ def execute(_input)
21
+ "Acknowledged."
22
+ end
23
+ end
24
+ end
25
+ end
@@ -20,7 +20,7 @@ module Mneme
20
20
  properties: {
21
21
  text: {
22
22
  type: "string",
23
- maxLength: Anima::Settings.mneme_max_tokens * Message::BYTES_PER_TOKEN
23
+ maxLength: Anima::Settings.mneme_max_tokens * TokenEstimation::BYTES_PER_TOKEN
24
24
  }
25
25
  },
26
26
  required: %w[text]
@@ -46,19 +46,11 @@ module Mneme
46
46
  text: text,
47
47
  from_message_id: @from_message_id,
48
48
  to_message_id: @to_message_id,
49
- level: @level,
50
- token_count: estimate_tokens(text)
49
+ level: @level
51
50
  )
52
51
 
53
52
  "Snapshot saved (id: #{snapshot.id}, messages #{@from_message_id}..#{@to_message_id})"
54
53
  end
55
-
56
- private
57
-
58
- # @return [Integer] estimated token count for the summary text
59
- def estimate_tokens(text)
60
- [(text.bytesize / Message::BYTES_PER_TOKEN.to_f).ceil, 1].max
61
- end
62
54
  end
63
55
  end
64
56
  end