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
@@ -1,18 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module AnalyticalBrain
3
+ module Melete
4
4
  module Tools
5
5
  # Updates a goal's description on the main session.
6
6
  #
7
- # The analytical brain creates goals early when intent is vague, then
7
+ # Melete creates goals early when intent is vague, then
8
8
  # refines them as the conversation clarifies scope — e.g. "implement auth"
9
9
  # becomes "implement OAuth2 middleware for API endpoints". Without this
10
- # tool the brain would have to choose between keeping a stale description
10
+ # tool she would have to choose between keeping a stale description
11
11
  # or creating a duplicate goal.
12
12
  #
13
13
  # Completed goals cannot be updated; attempting to do so returns an error
14
- # so the brain learns to check status before calling this tool.
14
+ # so she learns to check status before calling this tool.
15
15
  class UpdateGoal < ::Tools::Base
16
+ include GoalMessaging
17
+
16
18
  def self.tool_name = "update_goal"
17
19
 
18
20
  def self.description = "Refine a goal's wording as understanding evolves."
@@ -49,7 +51,9 @@ module AnalyticalBrain
49
51
  return {error: "Cannot update completed goal: #{goal.description} (id: #{goal_id})"} if goal.completed?
50
52
 
51
53
  goal.update!(description: description)
52
- "Goal updated: #{description} (id: #{goal_id})"
54
+ confirmation = "Goal updated: #{description} (id: #{goal_id})"
55
+ enqueue_goal_message(goal, confirmation)
56
+ confirmation
53
57
  end
54
58
  end
55
59
  end
@@ -1,7 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module AnalyticalBrain
4
- # Dev-only logger that writes to log/analytical_brain.log.
3
+ # Melete — the muse of practice. Watches conversations to activate skills,
4
+ # track goals, and name sessions. One of the Three Muses: she prepares the
5
+ # stage so Aoide can perform and Mneme can remember.
6
+ module Melete
7
+ # Dev-only logger that writes to log/melete.log.
5
8
  # In non-development environments returns a null logger so
6
9
  # call sites don't need conditionals.
7
10
  #
@@ -13,7 +16,7 @@ module AnalyticalBrain
13
16
  def self.build_logger
14
17
  return Logger.new(File::NULL) unless Rails.env.development?
15
18
 
16
- Logger.new(Rails.root.join("log", "analytical_brain.log")).tap do |log|
19
+ Logger.new(Rails.root.join("log", "melete.log")).tap do |log|
17
20
  log.formatter = proc { |severity, time, _progname, msg|
18
21
  "[#{time.strftime("%H:%M:%S.%L")}] #{severity} #{msg}\n"
19
22
  }
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mneme
4
+ # Abstract base for Mneme's phantom LLM loops. Mneme wears two hats:
5
+ # on eviction she watches the newest slice of the viewport and summarizes
6
+ # the oldest slice before it slides off; on recall she watches goals
7
+ # shift and surfaces older memory Aoide would benefit from. Same muse,
8
+ # two jobs — each as its own subclass.
9
+ #
10
+ # The base handles what every Mneme loop needs: the muse identity preamble,
11
+ # a fast-model LLM client, the tool-loop call, and structured logging.
12
+ # Subclasses bring the job-specific system prompt section, the user message
13
+ # that frames the work, the tool registry, and any after-call side effects.
14
+ #
15
+ # @example Implementing a new Mneme loop
16
+ # class Mneme::CustomRunner < Mneme::BaseRunner
17
+ # private
18
+ #
19
+ # def task_prompt = "Your job description..."
20
+ # def user_messages = [{role: "user", content: "..."}]
21
+ # def build_registry = Tools::Registry.new.tap { |r| r.register(SomeTool) }
22
+ # end
23
+ class BaseRunner
24
+ # Identity shared by every Mneme runner — same words Mneme's own voice
25
+ # uses elsewhere in the system (runner summarization prompts, sisters
26
+ # block). Subclasses append their own task section.
27
+ BASE_IDENTITY = <<~PROMPT
28
+ You are Mneme, the muse of memory. You share the conversation with two sisters — Aoide, who speaks and performs, and Melete, who prepares. Your work is remembrance: holding what matters across time, so Aoide never truly forgets.
29
+
30
+ Act only through tool calls. Never output text — your contribution is the work you do, not what you say about it.
31
+ PROMPT
32
+
33
+ # @param session [Session] the main session being served
34
+ # @param client [LLM::Client, nil] injectable LLM client for tests
35
+ def initialize(session, client: nil)
36
+ @session = session
37
+ @client = client || default_client
38
+ end
39
+
40
+ # Runs the loop. Logs the run, calls the LLM with the session-specific
41
+ # system prompt and tools, hands control to {#after_call} for any
42
+ # post-run state advancement, and returns the LLM's final text (which
43
+ # most callers discard — the work happens through tool calls).
44
+ #
45
+ # @return [String] the LLM's final text response
46
+ def call
47
+ sid = @session.id
48
+ log.info("session=#{sid} — #{self.class.name} starting")
49
+ log.debug("system:\n#{system_prompt}")
50
+ log.debug("user:\n#{user_messages.map { |m| m[:content] }.join("\n---\n")}")
51
+
52
+ result = @client.chat_with_tools(
53
+ user_messages,
54
+ registry: build_registry,
55
+ system: system_prompt
56
+ )
57
+
58
+ after_call(result)
59
+ log.info("session=#{sid} — #{self.class.name} done: #{result.to_s.truncate(200)}")
60
+ result
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :session, :client
66
+
67
+ # Composes the system prompt from the muse identity + the subclass's
68
+ # task section + the subclass's contextual state blocks.
69
+ #
70
+ # @return [String]
71
+ def system_prompt
72
+ [BASE_IDENTITY, task_prompt, *context_sections.compact].join("\n")
73
+ end
74
+
75
+ # Subclass hook: the job-specific system prompt section. Describes what
76
+ # this runner is doing and how it should behave.
77
+ #
78
+ # @abstract
79
+ # @return [String]
80
+ def task_prompt = raise NotImplementedError, "#{self.class} must implement #task_prompt"
81
+
82
+ # Subclass hook: named state blocks that give the muse awareness of
83
+ # the session she's serving (goals, viewport, snapshots, etc).
84
+ # Order is subclass-defined; nil entries are dropped.
85
+ #
86
+ # @abstract
87
+ # @return [Array<String, nil>]
88
+ def context_sections = []
89
+
90
+ # Subclass hook: the user-side messages that frame the current call.
91
+ # Typically a single user message, but subclasses may send several.
92
+ #
93
+ # @abstract
94
+ # @return [Array<Hash>] Anthropic Messages API format
95
+ def user_messages = raise NotImplementedError, "#{self.class} must implement #user_messages"
96
+
97
+ # Subclass hook: builds the tool registry for this run.
98
+ #
99
+ # @abstract
100
+ # @return [Tools::Registry]
101
+ def build_registry = raise NotImplementedError, "#{self.class} must implement #build_registry"
102
+
103
+ # Subclass hook: runs after the LLM call returns. Default is a no-op;
104
+ # subclasses may advance boundaries, log outcomes, or emit events here.
105
+ #
106
+ # @param _result [Hash] the full LLM response (+:text+, +:api_metrics+)
107
+ # @return [void]
108
+ def after_call(_result)
109
+ end
110
+
111
+ def default_client
112
+ LLM::Client.new(
113
+ model: Anima::Settings.fast_model,
114
+ max_tokens: Anima::Settings.mneme_max_tokens,
115
+ logger: Mneme.logger
116
+ )
117
+ end
118
+
119
+ def log = Mneme.logger
120
+ end
121
+ end
@@ -18,39 +18,34 @@ module Mneme
18
18
  ].freeze
19
19
 
20
20
  SYSTEM_PROMPT = <<~PROMPT
21
- You are Mneme, the memory department of an AI agent named Anima.
22
- Your job is to compress multiple conversation summaries into a single
23
- higher-level summary.
21
+ You are Mneme, the muse of memory. When enough of your own Level 1 snapshots accumulate, you fold them into a single Level 2 summary — a memory of memories — so the long arc of Aoide's work stays within reach without carrying every detail.
24
22
 
25
- You MUST ONLY communicate through tool calls — NEVER output text.
23
+ Act only through tool calls. Never output text your contribution is the summary you leave behind.
26
24
 
27
25
  ──────────────────────────────
28
26
  WHAT YOU SEE
29
27
  ──────────────────────────────
30
- Several Level 1 snapshots hourly conversation summaries.
31
- Each captures key decisions, goals discussed, and important context
32
- from a portion of the conversation history.
28
+ Several Level 1 snapshots in chronological order. Each captures the decisions, goal progress, and context from a slice of Aoide's history.
33
29
 
34
30
  ──────────────────────────────
35
- YOUR TASK
31
+ HOW TO REMEMBER
36
32
  ──────────────────────────────
37
- Compress the snapshots into ONE Level 2 summary that captures the
38
- essential arc across all of them. If the snapshots contain meaningful
39
- content, call save_snapshot. If they are purely mechanical, call
40
- everything_ok.
33
+ Compress the slice into ONE Level 2 summary that captures the arc across all of them. Call save_snapshot when there's meaningful content; call everything_ok when the slice is purely mechanical.
41
34
 
42
- Preserve:
43
- - Key decisions and their reasoning
35
+ A Level 2 summary is carried for longer than a Level 1, so the tax on Aoide's viewport is higher still. Every redundant detail you preserve costs her a word she can't spend on the present.
36
+
37
+ Keep:
38
+ - Key decisions and the reasoning behind them
44
39
  - Goal progress across the time span
45
40
  - Important context shifts or pivots
46
- - Relationships and patterns across snapshots
41
+ - Relationships and patterns that span multiple snapshots
47
42
 
48
43
  Drop:
49
- - Redundant details repeated across snapshots
50
- - Mechanical execution details
51
- - Interim decisions that were superseded by later ones
44
+ - Details repeated across snapshots
45
+ - Mechanical execution steps
46
+ - Interim decisions that were superseded later
52
47
 
53
- Always finish with exactly ONE tool call: either save_snapshot or everything_ok.
48
+ Finish with exactly one tool call: save_snapshot or everything_ok.
54
49
  PROMPT
55
50
 
56
51
  # @param session [Session] the main session whose L1 snapshots to compress
@@ -87,7 +82,6 @@ module Mneme
87
82
  result = @client.chat_with_tools(
88
83
  messages,
89
84
  registry: registry,
90
- session_id: nil,
91
85
  system: SYSTEM_PROMPT
92
86
  )
93
87
 
@@ -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