anima-core 1.4.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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +18 -20
  3. data/README.md +61 -95
  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 +13 -2
  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 +21 -10
  30. data/app/models/message.rb +47 -36
  31. data/app/models/pending_message.rb +276 -29
  32. data/app/models/pinned_message.rb +8 -3
  33. data/app/models/session.rb +468 -432
  34. data/app/models/snapshot.rb +11 -21
  35. data/bin/inspect-cassette +17 -4
  36. data/config/application.rb +1 -0
  37. data/config/initializers/event_subscribers.rb +71 -4
  38. data/config/initializers/inflections.rb +3 -1
  39. data/db/cable_structure.sql +3 -3
  40. data/db/migrate/20260407170803_remove_viewport_message_ids_from_sessions.rb +5 -0
  41. data/db/migrate/20260407180400_remove_mneme_snapshot_pointer_columns_from_sessions.rb +6 -0
  42. data/db/migrate/20260411120553_add_token_count_to_pinned_messages.rb +5 -0
  43. data/db/migrate/20260411172926_remove_active_skills_and_workflow_from_sessions.rb +6 -0
  44. data/db/migrate/20260412110625_replace_processing_with_aasm_state.rb +6 -0
  45. data/db/migrate/20260418150323_add_kind_and_message_type_to_pending_messages.rb +6 -0
  46. data/db/migrate/20260419120000_add_drain_fields_to_pending_messages.rb +7 -0
  47. data/db/migrate/20260419130000_drop_pending_messages_kind_default.rb +5 -0
  48. data/db/migrate/20260419140000_add_drain_indexes_to_pending_messages.rb +8 -0
  49. data/db/migrate/20260420100000_add_hud_visibility_to_sessions.rb +15 -0
  50. data/db/queue_structure.sql +13 -13
  51. data/db/structure.sql +44 -31
  52. data/lib/agents/registry.rb +1 -1
  53. data/lib/anima/settings.rb +7 -33
  54. data/lib/anima/version.rb +1 -1
  55. data/lib/events/authentication_required.rb +24 -0
  56. data/lib/events/bounce_back.rb +4 -4
  57. data/lib/events/eviction_completed.rb +28 -0
  58. data/lib/events/goal_created.rb +28 -0
  59. data/lib/events/goal_updated.rb +32 -0
  60. data/lib/events/llm_responded.rb +35 -0
  61. data/lib/events/message_created.rb +27 -0
  62. data/lib/events/message_updated.rb +25 -0
  63. data/lib/events/session_state_changed.rb +30 -0
  64. data/lib/events/skill_activated.rb +28 -0
  65. data/lib/events/start_melete.rb +36 -0
  66. data/lib/events/start_mneme.rb +33 -0
  67. data/lib/events/start_processing.rb +32 -0
  68. data/lib/events/subagent_evicted.rb +31 -0
  69. data/lib/events/subscribers/active_state_broadcaster.rb +27 -0
  70. data/lib/events/subscribers/authentication_broadcaster.rb +34 -0
  71. data/lib/events/subscribers/drain_kickoff.rb +20 -0
  72. data/lib/events/subscribers/eviction_broadcaster.rb +26 -0
  73. data/lib/events/subscribers/llm_response_handler.rb +111 -0
  74. data/lib/events/subscribers/melete_kickoff.rb +24 -0
  75. data/lib/events/subscribers/message_broadcaster.rb +34 -0
  76. data/lib/events/subscribers/mneme_kickoff.rb +24 -0
  77. data/lib/events/subscribers/mneme_scheduler.rb +21 -0
  78. data/lib/events/subscribers/persister.rb +6 -8
  79. data/lib/events/subscribers/session_state_broadcaster.rb +33 -0
  80. data/lib/events/subscribers/subagent_message_router.rb +26 -29
  81. data/lib/events/subscribers/subagent_visibility_broadcaster.rb +33 -0
  82. data/lib/events/subscribers/tool_response_creator.rb +33 -0
  83. data/lib/events/subscribers/transient_broadcaster.rb +1 -1
  84. data/lib/events/tool_executed.rb +34 -0
  85. data/lib/events/workflow_activated.rb +27 -0
  86. data/lib/llm/client.rb +41 -201
  87. data/lib/mcp/client_manager.rb +41 -46
  88. data/lib/mcp/stdio_transport.rb +9 -5
  89. data/lib/{analytical_brain → melete}/runner.rb +63 -68
  90. data/lib/{analytical_brain → melete}/tools/activate_skill.rb +1 -1
  91. data/lib/{analytical_brain → melete}/tools/assign_nickname.rb +2 -2
  92. data/lib/{analytical_brain → melete}/tools/everything_is_ready.rb +2 -2
  93. data/lib/{analytical_brain → melete}/tools/finish_goal.rb +3 -3
  94. data/lib/{analytical_brain → melete}/tools/goal_messaging.rb +4 -3
  95. data/lib/{analytical_brain → melete}/tools/read_workflow.rb +2 -2
  96. data/lib/{analytical_brain → melete}/tools/rename_session.rb +3 -3
  97. data/lib/{analytical_brain → melete}/tools/set_goal.rb +1 -1
  98. data/lib/{analytical_brain → melete}/tools/update_goal.rb +4 -4
  99. data/lib/{analytical_brain.rb → melete.rb} +6 -3
  100. data/lib/mneme/base_runner.rb +121 -0
  101. data/lib/mneme/l2_runner.rb +14 -20
  102. data/lib/mneme/recall_runner.rb +132 -0
  103. data/lib/mneme/runner.rb +118 -171
  104. data/lib/mneme/search.rb +104 -62
  105. data/lib/mneme/tools/nothing_to_surface.rb +25 -0
  106. data/lib/mneme/tools/save_snapshot.rb +2 -10
  107. data/lib/mneme/tools/surface_memory.rb +89 -0
  108. data/lib/mneme.rb +11 -5
  109. data/lib/shell_session.rb +287 -612
  110. data/lib/skills/definition.rb +2 -2
  111. data/lib/skills/registry.rb +1 -1
  112. data/lib/tools/base.rb +16 -0
  113. data/lib/tools/bash.rb +25 -57
  114. data/lib/tools/edit.rb +2 -0
  115. data/lib/tools/read.rb +2 -0
  116. data/lib/tools/registry.rb +79 -3
  117. data/lib/tools/{recall.rb → search_messages.rb} +19 -21
  118. data/lib/tools/spawn_specialist.rb +16 -10
  119. data/lib/tools/spawn_subagent.rb +20 -14
  120. data/lib/tools/subagent_prompts.rb +4 -4
  121. data/lib/tools/think.rb +1 -1
  122. data/lib/tools/{remember.rb → view_messages.rb} +10 -10
  123. data/lib/tools/write.rb +2 -0
  124. data/lib/tui/app.rb +5 -4
  125. data/lib/tui/braille_spinner.rb +7 -7
  126. data/lib/tui/decorators/base_decorator.rb +24 -3
  127. data/lib/tui/message_store.rb +93 -44
  128. data/lib/tui/screens/chat.rb +94 -20
  129. data/lib/tui/settings.rb +9 -2
  130. data/lib/workflows/definition.rb +3 -3
  131. data/lib/workflows/registry.rb +1 -1
  132. data/skills/github.md +38 -0
  133. data/templates/config.toml +4 -23
  134. data/workflows/review_pr.md +18 -14
  135. metadata +86 -28
  136. data/app/jobs/agent_request_job.rb +0 -199
  137. data/app/jobs/analytical_brain_job.rb +0 -33
  138. data/app/jobs/count_message_tokens_job.rb +0 -39
  139. data/app/jobs/passive_recall_job.rb +0 -24
  140. data/app/models/concerns/message/broadcasting.rb +0 -86
  141. data/lib/agent_loop.rb +0 -215
  142. data/lib/analytical_brain/tools/deactivate_skill.rb +0 -40
  143. data/lib/analytical_brain/tools/deactivate_workflow.rb +0 -35
  144. data/lib/events/agent_message.rb +0 -25
  145. data/lib/events/subscribers/message_collector.rb +0 -64
  146. data/lib/events/tool_call.rb +0 -31
  147. data/lib/events/tool_response.rb +0 -33
  148. data/lib/mneme/compressed_viewport.rb +0 -204
  149. data/lib/mneme/passive_recall.rb +0 -138
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ # Mneme in recall mode — a phantom LLM loop that decides whether any older
5
+ # memory would help Aoide with what she's working on now, and surfaces it
6
+ # if so. Triggered whenever Aoide's context shifts in ways worth
7
+ # re-remembering around (new user message, goal change).
8
+ #
9
+ # The muse searches long-term memory through her search tool (which
10
+ # automatically excludes Aoide's current viewport), drills into candidate
11
+ # messages when she needs to decide, and surfaces only what genuinely
12
+ # helps. Silence — nothing surfaced — is the default answer.
13
+ #
14
+ # This is not the eviction loop ({Runner}); same muse, different work.
15
+ #
16
+ # @example
17
+ # Mneme::RecallRunner.new(session).call
18
+ class RecallRunner < BaseRunner
19
+ TOOLS = [
20
+ ::Tools::SearchMessages,
21
+ ::Tools::ViewMessages,
22
+ Tools::SurfaceMemory,
23
+ Tools::NothingToSurface
24
+ ].freeze
25
+
26
+ TASK_PROMPT = <<~PROMPT
27
+ Right now your work is recall. Aoide's focus has just shifted — a new message, a changed goal — and you're here to decide whether any memory from before would genuinely help her now.
28
+
29
+ ──────────────────────────────
30
+ WHAT MAKES RECALL USEFUL
31
+ ──────────────────────────────
32
+ Recall is a tax on Aoide's viewport. Every memory you surface takes tokens away from the present exchange. Return empty-handed far more often than you return something. One well-chosen memory beats five that nearly-match. Most of the time, nothing is worth surfacing — and that is the right answer.
33
+
34
+ A memory is worth surfacing when it carries weight Aoide can't reconstruct from what's already in front of her: a prior decision about this exact problem, a specific constraint she encountered before, a voice from another session relevant to the one unfolding. Not tangential echoes. Not mere keyword overlap. Something she'd want to have remembered.
35
+
36
+ ──────────────────────────────
37
+ THE QUERY IS THE JUDGMENT
38
+ ──────────────────────────────
39
+ Composing your query is where the thinking happens. Read what Aoide is doing right now and ask: what specific words, phrases, or names would only appear in past messages that meaningfully help her? Not the topic. Not the domain. The signal that distinguishes "she'd want this" from "this contains overlapping vocabulary."
40
+
41
+ When a candidate looks promising but its meaning is unclear, read the full context around it before surfacing.
42
+ PROMPT
43
+
44
+ private
45
+
46
+ def task_prompt = TASK_PROMPT
47
+
48
+ def context_sections
49
+ [active_goals_section, already_surfaced_section]
50
+ end
51
+
52
+ def user_messages
53
+ trigger = recall_trigger_description
54
+ content = <<~MSG.strip
55
+ #{trigger}
56
+
57
+ Decide whether any older memory would help Aoide now. Search if something comes to mind, drill down when you're unsure, surface only what earns its place. Finish with nothing_to_surface when you're done — even if you surface something.
58
+ MSG
59
+ [{role: "user", content: content}]
60
+ end
61
+
62
+ def build_registry
63
+ registry = ::Tools::Registry.new(context: {
64
+ session: session,
65
+ main_session: session
66
+ })
67
+ TOOLS.each { |tool| registry.register(tool) }
68
+ registry
69
+ end
70
+
71
+ # Describes what just changed in Aoide's context — the reason Mneme
72
+ # woke. Today that's a goal list shift; the framing leaves room for
73
+ # other triggers later without rewriting.
74
+ def recall_trigger_description
75
+ goals_lines = root_goals.map { |goal| format_goal(goal) }
76
+
77
+ if goals_lines.empty?
78
+ "Aoide's context shifted — no active goals right now. If nothing comes to mind about this session's trajectory, that's fine; call nothing_to_surface."
79
+ else
80
+ <<~MSG.strip
81
+ Aoide's active goals right now:
82
+
83
+ #{goals_lines.join("\n")}
84
+ MSG
85
+ end
86
+ end
87
+
88
+ # Active goals block for the system-prompt context. Same content as
89
+ # the trigger's goal block — but mirroring it here lets future
90
+ # non-goal triggers still give the muse a stable view of the goals.
91
+ def active_goals_section
92
+ return if root_goals.empty?
93
+
94
+ lines = root_goals.map { |goal| format_goal(goal) }
95
+ "\n\n🎯 Active Goals\n#{lines.join("\n")}"
96
+ end
97
+
98
+ # Memory IDs Mneme has surfaced recently whose phantom pairs still
99
+ # sit in Aoide's viewport — so she doesn't surface the same thing
100
+ # twice in one conversation. Search already filters by boundary;
101
+ # this block makes the same constraint visible to the muse so she
102
+ # can reason around it rather than being silently restricted.
103
+ def already_surfaced_section
104
+ surfaced_ids = surfaced_message_ids_in_viewport
105
+ return if surfaced_ids.empty?
106
+
107
+ "\n\n📚 Memories You've Already Surfaced This Cycle\nmessage ids: #{surfaced_ids.join(", ")}"
108
+ end
109
+
110
+ def root_goals
111
+ @root_goals ||= session.goals.root.includes(:sub_goals).active.order(:created_at).to_a
112
+ end
113
+
114
+ def format_goal(goal)
115
+ parts = [" ● #{goal.description} (id: #{goal.id})"]
116
+ goal.sub_goals.each do |sub|
117
+ checkbox = sub.completed? ? "[x]" : "[ ]"
118
+ parts << " #{checkbox} #{sub.description} (id: #{sub.id})"
119
+ end
120
+ parts.join("\n")
121
+ end
122
+
123
+ def surfaced_message_ids_in_viewport
124
+ session.viewport_messages
125
+ .where(message_type: "tool_call")
126
+ .where("payload ->> 'tool_name' = ?", PendingMessage::MNEME_TOOL)
127
+ .pluck(Arel.sql("json_extract(payload, '$.tool_input.message_id')"))
128
+ .compact
129
+ .map(&:to_i)
130
+ end
131
+ end
132
+ end
data/lib/mneme/runner.rb CHANGED
@@ -1,209 +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.
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.
55
- PROMPT
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.
56
38
 
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
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.
82
40
 
83
- llm_messages = build_messages(compressed_text)
84
- system = SYSTEM_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.
85
42
 
86
- log.info("session=#{sid}running Mneme (#{viewport.messages.size} messages)")
87
- log.debug("compressed viewport:\n#{compressed_text}")
43
+ If the eviction zone holds only mechanical activity tool calls, no conversation — call everything_ok and let it fall off without a snapshot.
88
44
 
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
51
+
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
115
57
 
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
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 past the zone Mneme just processed.
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 the boundary to the first conversation/think message AFTER Mneme's
157
- # viewport — the start of the remaining context. This creates the batch
158
- # eviction cycle: the next Mneme trigger fires only after this boundary
159
- # message itself falls out of the main viewport (~1/3 turnover later).
160
- # Also updates the snapshot range pointers.
161
- #
162
- # @param viewport [Mneme::CompressedViewport]
163
- def advance_boundary(viewport)
164
- viewport_messages = viewport.messages
165
- return if viewport_messages.empty?
166
-
167
- last_processed_id = viewport_messages.last.id
168
- new_boundary = @session.messages
169
- .where("id > ?", last_processed_id)
170
- .where(message_type: Message::CONVERSATION_TYPES + ["tool_call"])
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)
171
91
  .order(:id)
172
- .find_each { |msg| break msg if conversation_or_think?(msg) }
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
173
102
 
174
- # Fall back to the last message in Mneme's viewport when no conversation
175
- # messages exist beyond it (e.g. session went quiet after the zone).
176
- new_boundary ||= viewport_messages.reverse_each.find { |msg| conversation_or_think?(msg) }
177
- return unless new_boundary
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
118
+ end
178
119
 
179
- boundary_id = new_boundary.id
180
- updates = {mneme_boundary_message_id: boundary_id}
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")
129
+ end
181
130
 
182
- updates[:mneme_snapshot_first_message_id] = viewport_messages.first.id unless @session.mneme_snapshot_first_message_id
183
- updates[:mneme_snapshot_last_message_id] = viewport_messages.last.id
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
184
151
 
185
- @session.update_columns(updates)
186
- log.debug("session=#{@session.id} — boundary advanced to message #{boundary_id}")
152
+ lines << flush_tool_count(tool_count) if tool_count > 0
153
+ lines.join("\n")
187
154
  end
188
155
 
189
- # Delegates to {Message#conversation_or_think?} — single source of truth
190
- # for which messages Mneme treats as conversation boundaries.
191
- #
192
- # @return [Boolean]
193
- def conversation_or_think?(message)
194
- message.conversation_or_think?
156
+ def flush_tool_count(count)
157
+ "[#{count} #{(count == 1) ? "tool" : "tools"} called]"
195
158
  end
196
159
 
197
- # Builds the active goals section for Mneme's context so it knows
198
- # what Goals exist, which messages are already pinned, and can reference
199
- # them when deciding what to pin or summarize.
200
- #
201
- # @return [String] formatted goals section, or empty string
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.
202
163
  def active_goals_section
203
- 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)
204
165
  return "" if root_goals.empty?
205
166
 
206
- lines = root_goals.map { |goal| format_goal_for_mneme(goal) }
167
+ lines = root_goals.map { |goal| format_goal(goal) }
207
168
  pinned = format_existing_pins
208
169
 
209
170
  section = "\n\n🎯 Active Goals\n#{lines.join("\n")}\n"
@@ -211,11 +172,7 @@ module Mneme
211
172
  section
212
173
  end
213
174
 
214
- # Formats a goal with sub-goals for Mneme's context.
215
- #
216
- # @param goal [Goal] root goal with preloaded sub_goals
217
- # @return [String]
218
- def format_goal_for_mneme(goal)
175
+ def format_goal(goal)
219
176
  parts = [" ● #{goal.description} (id: #{goal.id})"]
220
177
  goal.sub_goals.each do |sub|
221
178
  checkbox = sub.completed? ? "[x]" : "[ ]"
@@ -224,24 +181,14 @@ module Mneme
224
181
  parts.join("\n")
225
182
  end
226
183
 
227
- # Lists already-pinned message IDs so Mneme avoids redundant pinning.
228
- #
229
- # @return [String, nil] formatted pin list, or nil when nothing is pinned
230
184
  def format_existing_pins
231
- pins = @session.pinned_messages.includes(:goals).order(:message_id)
185
+ pins = session.pinned_messages.includes(:goals).order(:message_id)
232
186
  return nil if pins.empty?
233
187
 
234
- 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")
235
192
  end
236
-
237
- # @param pin [PinnedMessage] pin with preloaded goals
238
- # @return [String] formatted pin line
239
- def format_pin_for_mneme(pin)
240
- goal_ids = pin.goals.map(&:id).join(", ")
241
- " message #{pin.message_id} → goals [#{goal_ids}]"
242
- end
243
-
244
- # @return [Logger]
245
- def log = Mneme.logger
246
193
  end
247
194
  end