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
@@ -10,7 +10,7 @@ module Skills
10
10
  # content injected into the main agent's system prompt when active.
11
11
  #
12
12
  # Skills are passive knowledge — they describe WHAT you know, not
13
- # WHAT to do. The analytical brain activates/deactivates them based
13
+ # WHAT to do. Melete activates/deactivates them based
14
14
  # on conversation context.
15
15
  #
16
16
  # @example Skill file format
@@ -25,7 +25,7 @@ module Skills
25
25
  # @return [String] unique skill identifier used in activate_skill(name: "...")
26
26
  attr_reader :name
27
27
 
28
- # @return [String] description shown to the analytical brain for relevance matching
28
+ # @return [String] description shown to Melete for relevance matching
29
29
  attr_reader :description
30
30
 
31
31
  # @return [String] knowledge content (Markdown body) injected into system prompt
@@ -69,7 +69,7 @@ module Skills
69
69
  @skills[name]
70
70
  end
71
71
 
72
- # Skill names and descriptions for inclusion in the analytical brain's context.
72
+ # Skill names and descriptions for inclusion in Melete's context.
73
73
  #
74
74
  # @return [Hash{String => String}] name => description
75
75
  def catalog
data/lib/tools/base.rb CHANGED
@@ -50,6 +50,22 @@ module Tools
50
50
  def truncation_threshold
51
51
  Anima::Settings.max_tool_response_chars
52
52
  end
53
+
54
+ # One-line entry rendered into the system prompt's "## Available Tools"
55
+ # menu. Tools that return +nil+ are omitted from the menu.
56
+ #
57
+ # @return [String, nil] short capability statement, or nil to skip
58
+ def prompt_snippet
59
+ nil
60
+ end
61
+
62
+ # Cross-tool behavioral guidelines merged into the system prompt's
63
+ # "## Tool Guidelines" section. Each entry becomes a Markdown bullet.
64
+ #
65
+ # @return [Array<String>] guideline lines, or empty array to skip
66
+ def prompt_guidelines
67
+ []
68
+ end
53
69
  end
54
70
 
55
71
  # Subclasses whose schema depends on runtime context (e.g. session state,
data/lib/tools/bash.rb CHANGED
@@ -3,12 +3,16 @@
3
3
  module Tools
4
4
  # Executes bash commands in a persistent {ShellSession}. Commands share
5
5
  # working directory, environment variables, and shell history within a
6
- # conversation. Output is truncated and timeouts are enforced by the
7
- # underlying session.
6
+ # conversation. Output is the rendered terminal text exactly as a human
7
+ # would see it — including the prompt, which doubles as live cwd/branch
8
+ # telemetry for the agent.
8
9
  #
9
- # Supports two modes:
10
- # - Single command via +command+ (string) — backward compatible
11
- # - Batch via +commands+ (array) with +mode+ controlling error handling
10
+ # Two input shapes:
11
+ # - +command+ (string) — one command, one result.
12
+ # - +commands+ (array) runs each command in order in the same shell;
13
+ # all run regardless of failures (the agent reads merged output and
14
+ # decides what to do). Use shell chaining (+&&+) inside a single
15
+ # command if you need fail-fast.
12
16
  #
13
17
  # @see ShellSession#run
14
18
  class Bash < Base
@@ -16,6 +20,8 @@ module Tools
16
20
 
17
21
  def self.description = "Execute shell commands. Working directory and environment persist between calls."
18
22
 
23
+ def self.prompt_snippet = "Run shell commands."
24
+
19
25
  def self.input_schema
20
26
  {
21
27
  type: "object",
@@ -26,12 +32,7 @@ module Tools
26
32
  commands: {
27
33
  type: "array",
28
34
  items: {type: "string"},
29
- description: "Each command gets its own timeout and result."
30
- },
31
- mode: {
32
- type: "string",
33
- enum: ["sequential", "parallel"],
34
- description: "sequential (default) stops on first failure."
35
+ description: "Each command gets its own timeout and result. All commands run regardless of failures — use a single command with shell chaining if you need fail-fast."
35
36
  }
36
37
  }
37
38
  }
@@ -47,7 +48,7 @@ module Tools
47
48
  # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API.
48
49
  # Supports optional "timeout" key (seconds) to override the global
49
50
  # command_timeout setting for long-running operations.
50
- # @return [String] formatted output with stdout, stderr, and exit code
51
+ # @return [String] rendered terminal output
51
52
  # @return [Hash] with :error key on failure
52
53
  def execute(input)
53
54
  timeout = input["timeout"]
@@ -57,7 +58,7 @@ module Tools
57
58
  if has_command && has_commands
58
59
  {error: "Provide either 'command' or 'commands', not both"}
59
60
  elsif has_commands
60
- execute_batch(input["commands"], mode: input.fetch("mode", "sequential"), timeout: timeout)
61
+ execute_batch(input["commands"], timeout: timeout)
61
62
  elsif has_command
62
63
  execute_single(input["command"], timeout: timeout)
63
64
  else
@@ -77,30 +78,26 @@ module Tools
77
78
  return format_interrupted(result) if result[:interrupted]
78
79
  return result if result.key?(:error)
79
80
 
80
- output = format_result(result[:stdout], result[:stderr], result[:exit_code])
81
- append_env_summary(output, result[:env_summary])
81
+ result[:output]
82
82
  end
83
83
 
84
- # Executes an array of commands, returning a combined result string.
85
- # Checks for user interrupt between commands and during each command
86
- # via the {ShellSession} interrupt_check callback.
84
+ # Executes an array of commands sequentially through the shared
85
+ # shell. Continues past errors the LLM reads the merged output
86
+ # and decides what to do. The only short-circuit is a user interrupt,
87
+ # which skips the remaining commands.
87
88
  #
88
89
  # @param commands [Array<String>] commands to execute
89
- # @param mode [String] "sequential" (stop on first failure) or "parallel" (run all)
90
90
  # @param timeout [Integer, nil] per-command timeout override
91
91
  # @return [String] combined results with per-command headers
92
92
  # @return [Hash] with :error key if commands array is invalid
93
- def execute_batch(commands, mode:, timeout: nil)
93
+ def execute_batch(commands, timeout: nil)
94
94
  return {error: "Commands array cannot be empty"} unless commands.is_a?(Array) && commands.any?
95
95
 
96
96
  checker = interrupt_checker
97
97
  total = commands.size
98
98
  results = []
99
- failed = false
100
99
  interrupted = false
101
100
 
102
- last_env_summary = nil
103
-
104
101
  commands.each_with_index do |command, index|
105
102
  position = "[#{index + 1}/#{total}]"
106
103
 
@@ -109,11 +106,6 @@ module Tools
109
106
  next
110
107
  end
111
108
 
112
- if failed && mode == "sequential"
113
- results << "#{position} $ #{command}\n(skipped)"
114
- next
115
- end
116
-
117
109
  command = command.to_s
118
110
  if command.strip.empty?
119
111
  results << "#{position} $ (blank)\n(skipped — blank command)"
@@ -127,47 +119,23 @@ module Tools
127
119
  interrupted = true
128
120
  elsif result.key?(:error)
129
121
  results << "#{position} $ #{command}\n#{result[:error]}"
130
- failed = true
131
122
  else
132
- exit_code = result[:exit_code]
133
- output = format_result(result[:stdout], result[:stderr], exit_code)
134
- results << "#{position} $ #{command}\n#{output}"
135
- last_env_summary = result[:env_summary]
136
- failed = true if exit_code != 0
123
+ results << "#{position} $ #{command}\n#{result[:output]}"
137
124
  end
138
125
  end
139
126
 
140
- append_env_summary(results.join("\n\n"), last_env_summary)
141
- end
142
-
143
- # Appends environment summary to tool output when present.
144
- #
145
- # @param output [String] formatted tool response
146
- # @param env_summary [String, nil] natural-language environment change summary
147
- # @return [String] output with env summary appended
148
- def append_env_summary(output, env_summary)
149
- env_summary ? "#{output}\n\n#{env_summary}" : output
150
- end
151
-
152
- def format_result(stdout, stderr, exit_code)
153
- parts = []
154
- parts << "stdout:\n#{stdout}" unless stdout.empty?
155
- parts << "stderr:\n#{stderr}" unless stderr.empty?
156
- parts << "exit_code: #{exit_code}"
157
- parts.join("\n\n")
127
+ results.join("\n\n")
158
128
  end
159
129
 
160
130
  # Formats the result of an interrupted command for the LLM.
161
131
  # Includes partial output captured before the interrupt.
162
132
  #
163
- # @param result [Hash] ShellSession result with :stdout, :stderr keys
133
+ # @param result [Hash] ShellSession result with :output key
164
134
  # @return [String] formatted message for the LLM
165
135
  def format_interrupted(result)
166
- stdout = result[:stdout].to_s
167
- stderr = result[:stderr].to_s
136
+ output = result[:output].to_s
168
137
  parts = [LLM::Client::INTERRUPT_MESSAGE]
169
- parts << "Partial stdout:\n#{stdout}" unless stdout.empty?
170
- parts << "stderr:\n#{stderr}" unless stderr.empty?
138
+ parts << "Partial output:\n#{output}" unless output.empty?
171
139
  parts.join("\n\n")
172
140
  end
173
141
 
data/lib/tools/edit.rb CHANGED
@@ -20,6 +20,8 @@ module Tools
20
20
 
21
21
  def self.description = "Replace text in a file."
22
22
 
23
+ def self.prompt_snippet = "Replace exact text in a file."
24
+
23
25
  def self.input_schema
24
26
  {
25
27
  type: "object",
data/lib/tools/read.rb CHANGED
@@ -21,6 +21,8 @@ module Tools
21
21
 
22
22
  def self.description = "Read file. Relative paths resolve against working directory."
23
23
 
24
+ def self.prompt_snippet = "Read a file."
25
+
24
26
  def self.input_schema
25
27
  {
26
28
  type: "object",
@@ -14,6 +14,76 @@ module Tools
14
14
  # registry.register(Tools::Bash)
15
15
  # registry.execute("bash", {"command" => "ls"})
16
16
  class Registry
17
+ # Standard tools available to every session unless filtered out by
18
+ # {Session#granted_tools}.
19
+ STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::ViewMessages, Tools::SearchMessages].freeze
20
+
21
+ # Tools that bypass {Session#granted_tools} filtering — the agent's
22
+ # reasoning depends on them regardless of task scope.
23
+ ALWAYS_GRANTED_TOOLS = [Tools::Think].freeze
24
+
25
+ # Name-to-class mapping for granted-tools filtering.
26
+ STANDARD_TOOLS_BY_NAME = STANDARD_TOOLS.index_by(&:tool_name).freeze
27
+
28
+ class << self
29
+ # Builds a registry appropriate for the given session: standard tools
30
+ # filtered through {Session#granted_tools}, plus spawn tools for main
31
+ # sessions or +mark_goal_completed+ for sub-agents, plus any tools
32
+ # exposed by configured MCP servers.
33
+ #
34
+ # MCP registration warnings are emitted as system messages so both the
35
+ # user (in verbose mode) and the LLM see them.
36
+ #
37
+ # @param session [Session] the session requesting tools
38
+ # @param shell_session [ShellSession] persistent PTY for Bash-family tools
39
+ # @return [Registry] configured registry
40
+ def build(session:, shell_session:)
41
+ registry = new(context: {shell_session: shell_session, session: session})
42
+
43
+ tool_classes_for(session).each { |tool| registry.register(tool) }
44
+
45
+ Mcp::ClientManager.shared.register_tools(registry).each do |message|
46
+ Events::Bus.emit(Events::SystemMessage.new(content: message, session_id: session.id))
47
+ end
48
+
49
+ registry
50
+ end
51
+
52
+ # Resolves the ordered tool-class list for a session: granted standard
53
+ # tools (or all of them when +granted_tools+ is nil), plus spawn tools
54
+ # for main sessions or +mark_goal_completed+ for sub-agents. MCP tools
55
+ # are excluded — they are dynamic and registered separately by
56
+ # {.build}. Single source of truth for {.build}, {Session#tool_schemas},
57
+ # and the system-prompt section assemblers.
58
+ #
59
+ # @param session [Session]
60
+ # @return [Array<Class<Tools::Base>>]
61
+ def tool_classes_for(session)
62
+ tools = granted_standard_tools(session).dup
63
+
64
+ if session.sub_agent?
65
+ tools.push(Tools::MarkGoalCompleted)
66
+ else
67
+ tools.push(Tools::SpawnSubagent, Tools::SpawnSpecialist, Tools::OpenIssue)
68
+ end
69
+
70
+ tools
71
+ end
72
+
73
+ private
74
+
75
+ # Filters {STANDARD_TOOLS} through the session's granted list.
76
+ # Always includes {ALWAYS_GRANTED_TOOLS} so the agent retains core
77
+ # reasoning tools regardless of task scope.
78
+ def granted_standard_tools(session)
79
+ granted = session.granted_tools
80
+ return STANDARD_TOOLS unless granted
81
+
82
+ explicitly_granted = granted.filter_map { |name| STANDARD_TOOLS_BY_NAME[name] }
83
+ (ALWAYS_GRANTED_TOOLS + explicitly_granted).uniq
84
+ end
85
+ end
86
+
17
87
  # @return [Hash{String => Class, Object}] registered tools keyed by name
18
88
  attr_reader :tools
19
89
 
@@ -49,16 +119,22 @@ module Tools
49
119
  end
50
120
 
51
121
  # Execute a tool by name. Classes are instantiated with the registry's
52
- # context; instances are called directly.
122
+ # context; instances are called directly. The enclosing +tool_use_id+
123
+ # is merged into the context when provided so tools that need to
124
+ # reference their own invoking tool_call (e.g. {Tools::SpawnSubagent}
125
+ # persisting +spawn_tool_use_id+ on the child session) can read it via
126
+ # a named kwarg in their initializer.
53
127
  #
54
128
  # @param name [String] registered tool name
55
129
  # @param input [Hash] tool input parameters (may include "timeout" for
56
130
  # tools that support per-call timeout overrides)
131
+ # @param tool_use_id [String, nil] the invoking tool_call's pairing id
57
132
  # @return [String, Hash] tool execution result
58
133
  # @raise [UnknownToolError] if no tool is registered with the given name
59
- def execute(name, input)
134
+ def execute(name, input, tool_use_id: nil)
60
135
  tool = @tools.fetch(name) { raise UnknownToolError, "Unknown tool: #{name}" }
61
- instance = tool.is_a?(Class) ? tool.new(**@context) : tool
136
+ context = tool_use_id ? @context.merge(tool_use_id: tool_use_id) : @context
137
+ instance = tool.is_a?(Class) ? tool.new(**context) : tool
62
138
  instance.execute(input)
63
139
  end
64
140
 
@@ -1,51 +1,49 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tools
4
- # Active memory search keyword lookup across conversation history.
5
- # Returns ranked snippets with message IDs for drill-down via {Remember}.
4
+ # Keyword search across long-term memory — every message Anima has
5
+ # ever seen, across every session, *except* what the agent is already
6
+ # looking at right now. Results sit below her current viewport; anything
7
+ # still in front of her is excluded, so every slot the search returns is
8
+ # new information.
6
9
  #
7
10
  # Two-step memory workflow:
8
- # 1. `recall(query: "auth flow")` → discovers relevant messages
9
- # 2. `remember(message_id: 42)` → fractal zoom into full context
11
+ # 1. `search_messages(query: "auth flow")` → discovers relevant messages
12
+ # 2. `view_messages(message_id: 42)` → fractal zoom into full context
10
13
  #
11
- # Wraps {Mneme::Search} same FTS5 engine used by passive recall,
12
- # but triggered on demand by the agent instead of automatically by goals.
14
+ # Same FTS5 engine Mneme uses for passive recall this variant fires
15
+ # on demand when Aoide reaches for a memory herself.
13
16
  #
14
- # @example Search all sessions
15
- # recall(query: "authentication flow")
16
- #
17
- # @example Search current session only
18
- # recall(query: "OAuth config", session_only: true)
19
- class Recall < Base
20
- def self.tool_name = "recall"
17
+ # @example
18
+ # search_messages(query: "authentication flow")
19
+ class SearchMessages < Base
20
+ def self.tool_name = "search_messages"
21
21
 
22
- def self.description = "Find messages across past conversations by keywords."
22
+ def self.description = "Search long-term memory (past conversations outside your current viewport) by keyword. Returns ranked message snippets with IDs — pass any ID to view_messages to see the full context around it."
23
23
 
24
24
  def self.input_schema
25
25
  {
26
26
  type: "object",
27
27
  properties: {
28
- query: {type: "string"},
29
- session_only: {type: "boolean", description: "Default: all sessions"}
28
+ query: {type: "string"}
30
29
  },
31
30
  required: ["query"]
32
31
  }
33
32
  end
34
33
 
35
- # @param session [Session] the current session (used for session_only scoping)
34
+ # @param session [Session] the calling session drives viewport exclusion
36
35
  def initialize(session:, **)
37
36
  @session = session
38
37
  end
39
38
 
40
- # @param input [Hash] with "query" and optional "session_only"
39
+ # @param input [Hash] with "query"
41
40
  # @return [String] formatted search results with message IDs
42
41
  # @return [Hash] with :error key when query is blank
43
42
  def execute(input)
44
43
  query = input["query"].to_s.strip
45
44
  return {error: "Query cannot be blank"} if query.empty?
46
45
 
47
- session_id = (input["session_only"] == true) ? @session.id : nil
48
- results = Mneme::Search.query(query, session_id: session_id)
46
+ results = Mneme::Search.query(query, caller_session: @session)
49
47
 
50
48
  return "No results found for \"#{query}\"." if results.empty?
51
49
 
@@ -55,7 +53,7 @@ module Tools
55
53
  private
56
54
 
57
55
  # Formats results as token-efficient, LLM-readable output.
58
- # Each result includes message_id for drill-down via remember tool.
56
+ # Each result includes message_id for drill-down via view_messages.
59
57
  #
60
58
  # @param query [String] the original search query
61
59
  # @param results [Array<Mneme::Search::Result>] ranked search results
@@ -5,7 +5,7 @@ module Tools
5
5
  # The specialist has a predefined system prompt and tool set defined
6
6
  # in its Markdown definition file under agents/.
7
7
  #
8
- # Nickname assignment is handled by the {AnalyticalBrain::Runner} which
8
+ # Nickname assignment is handled by the {Melete::Runner} which
9
9
  # runs synchronously at spawn time, generating a unique nickname based
10
10
  # on the task — same as generic sub-agents.
11
11
  #
@@ -58,17 +58,22 @@ module Tools
58
58
  private_class_method :name_property
59
59
 
60
60
  # @param session [Session] the parent session spawning the specialist
61
- # @param shell_session [ShellSession] the parent's persistent shell (for CWD inheritance)
62
61
  # @param agent_registry [Agents::Registry, nil] injectable for testing
63
- def initialize(session:, shell_session:, agent_registry: nil, **)
62
+ # @param tool_use_id [String, nil] the invoking +spawn_specialist+ tool_call's
63
+ # pairing id, captured so the spawn pair can later be located by the
64
+ # HUD visibility sweep in {Mneme::Runner}
65
+ def initialize(session:, agent_registry: nil, tool_use_id: nil, **)
64
66
  @session = session
65
- @shell_session = shell_session
66
67
  @agent_registry = agent_registry || Agents::Registry.instance
68
+ @tool_use_id = tool_use_id
67
69
  end
68
70
 
69
- # Creates a child session with the specialist's predefined prompt and tools,
70
- # persists the task as a user message, and queues background processing.
71
- # Returns immediately (non-blocking).
71
+ # Creates a child session with the specialist's predefined prompt and
72
+ # tools, pins the task as a Goal, and enqueues the task as the
73
+ # child's first user_message PendingMessage — which kicks the
74
+ # standard inbound pipeline (Melete → (Mneme) → StartProcessing →
75
+ # DrainJob) so the specialist self-starts the same way a human-typed
76
+ # message would. Returns immediately after Melete completes.
72
77
  #
73
78
  # @param input [Hash<String, Object>] with "name" and "task"
74
79
  # @return [String] confirmation with child session ID
@@ -97,12 +102,13 @@ module Tools
97
102
  parent_session_id: @session.id,
98
103
  prompt: build_prompt(definition),
99
104
  granted_tools: definition.tools,
100
- initial_cwd: @shell_session.pwd
105
+ spawn_tool_use_id: @tool_use_id,
106
+ initial_cwd: ShellSession.cwd_via_tmux(@session.id) || @session.initial_cwd
101
107
  )
102
108
  create_goal_with_pinned_task(child, task)
103
- assign_nickname_via_brain(child)
109
+ assign_nickname_via_melete(child)
104
110
  child.broadcast_children_update_to_parent
105
- AgentRequestJob.perform_later(child.id)
111
+ child.enqueue_user_message(task)
106
112
  child
107
113
  end
108
114
 
@@ -4,11 +4,11 @@ module Tools
4
4
  # Spawns a generic child session that works on a task autonomously.
5
5
  # The sub-agent starts clean — no parent conversation history — with
6
6
  # only a system prompt, a Goal, and the task as its first user message.
7
- # Runs via {AgentRequestJob} and communicates with the parent through
7
+ # Runs via {DrainJob} and communicates with the parent through
8
8
  # natural text messages routed by {Events::Subscribers::SubagentMessageRouter}.
9
9
  #
10
- # Nickname assignment is handled by the {AnalyticalBrain::Runner} which
11
- # runs synchronously at spawn time — the same brain that manages skills,
10
+ # Nickname assignment is handled by the {Melete::Runner} which
11
+ # runs synchronously at spawn time — the same muse that manages skills,
12
12
  # goals, and workflows for the main session.
13
13
  #
14
14
  # For named specialists with predefined prompts and tools, see {SpawnSpecialist}.
@@ -36,7 +36,7 @@ module Tools
36
36
  items: {type: "string"},
37
37
  description: "Tool names to grant the sub-agent. " \
38
38
  "Omit for all standard tools. Empty array for pure reasoning. " \
39
- "Valid tools: #{AgentLoop::STANDARD_TOOLS_BY_NAME.keys.join(", ")}"
39
+ "Valid tools: #{Tools::Registry::STANDARD_TOOLS_BY_NAME.keys.join(", ")}"
40
40
  }
41
41
  },
42
42
  required: %w[task]
@@ -44,16 +44,21 @@ module Tools
44
44
  end
45
45
 
46
46
  # @param session [Session] the parent session spawning the sub-agent
47
- # @param shell_session [ShellSession] the parent's persistent shell (for CWD inheritance)
48
- def initialize(session:, shell_session:, **)
47
+ # @param tool_use_id [String, nil] the invoking +spawn_subagent+ tool_call's
48
+ # pairing id, captured so the spawn pair can later be located by the
49
+ # HUD visibility sweep in {Mneme::Runner}
50
+ def initialize(session:, tool_use_id: nil, **)
49
51
  @session = session
50
- @shell_session = shell_session
52
+ @tool_use_id = tool_use_id
51
53
  end
52
54
 
53
55
  # Creates a child session with a clean context (no parent history),
54
- # runs the analytical brain to assign a nickname, persists the task
55
- # as a pinned user message, and queues background processing.
56
- # Returns immediately after brain completes (blocking for ~200ms).
56
+ # runs Melete to assign a nickname, pins the task as a Goal, and
57
+ # enqueues the task as the child's first user_message PendingMessage —
58
+ # which kicks the standard inbound pipeline (Melete (Mneme)
59
+ # StartProcessing → DrainJob) so the sub-agent self-starts the same
60
+ # way a human-typed message would. Returns immediately after Melete
61
+ # completes (blocking for ~200ms).
57
62
  #
58
63
  # @param input [Hash<String, Object>] with "task" and optional "tools"
59
64
  # @return [String] confirmation with child session ID and @nickname
@@ -82,12 +87,13 @@ module Tools
82
87
  parent_session_id: @session.id,
83
88
  prompt: GENERIC_PROMPT,
84
89
  granted_tools: granted_tools,
85
- initial_cwd: @shell_session.pwd
90
+ spawn_tool_use_id: @tool_use_id,
91
+ initial_cwd: ShellSession.cwd_via_tmux(@session.id) || @session.initial_cwd
86
92
  )
87
93
  create_goal_with_pinned_task(child, task)
88
- assign_nickname_via_brain(child)
94
+ assign_nickname_via_melete(child)
89
95
  child.broadcast_children_update_to_parent
90
- AgentRequestJob.perform_later(child.id)
96
+ child.enqueue_user_message(task)
91
97
  child
92
98
  end
93
99
 
@@ -109,7 +115,7 @@ module Tools
109
115
  return nil unless tools
110
116
  return {error: "tools must be an array"} unless tools.is_a?(Array)
111
117
 
112
- unknown = tools - AgentLoop::STANDARD_TOOLS_BY_NAME.keys
118
+ unknown = tools - Tools::Registry::STANDARD_TOOLS_BY_NAME.keys
113
119
  return {error: "Unknown tool: #{unknown.first}"} if unknown.any?
114
120
 
115
121
  nil
@@ -30,13 +30,13 @@ module Tools
30
30
  GoalPinnedMessage.create!(goal: goal, pinned_message: pin)
31
31
  end
32
32
 
33
- # Runs the analytical brain synchronously to assign a nickname,
33
+ # Runs Melete synchronously to assign a nickname,
34
34
  # then prepends identity context to the stored prompt.
35
35
  # Falls back to a sequential "agent-N" name on any failure.
36
36
  # Identity injection runs in +ensure+ so it applies to both
37
- # brain-assigned and fallback nicknames.
38
- def assign_nickname_via_brain(child)
39
- AnalyticalBrain::Runner.new(child).call
37
+ # Melete-assigned and fallback nicknames.
38
+ def assign_nickname_via_melete(child)
39
+ Melete::Runner.new(child).call
40
40
  child.reload
41
41
  rescue => error
42
42
  Rails.logger.warn("Sub-agent nickname assignment failed: #{error.message}")
data/lib/tools/think.rb CHANGED
@@ -5,7 +5,7 @@ module Tools
5
5
  # pause between tool calls where the agent can organize thoughts, plan
6
6
  # next steps, or make decisions without interrupting the user.
7
7
  #
8
- # Think events bridge the gap between the analytical brain (subconscious
8
+ # Think events bridge the gap between Melete (subconscious
9
9
  # background processing) and speech (user-facing messages). Without this
10
10
  # tool, reasoning leaks into tool arguments as comments.
11
11
  #
@@ -1,21 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tools
4
- # Fractal-resolution zoom into message history. Returns a window centered
5
- # on a target message with full detail at the center and compressed context
6
- # at the edges — sharp fovea, blurry periphery.
4
+ # Fractal-resolution window into long-term memory. Given a message_id,
5
+ # returns the surrounding conversation with full detail at the center
6
+ # and compressed snapshots at the edges — sharp fovea, blurry periphery.
7
7
  #
8
8
  # Output structure:
9
9
  # [Previous snapshots — compressed context before]
10
10
  # [Messages N-M — full detail, tool_responses compressed to checkmarks]
11
11
  # [Following snapshots — compressed context after]
12
12
  #
13
- # The agent discovers target messages via FTS5 search results embedded in
14
- # viewport recall snippets. This tool drills down into the full context.
13
+ # Aoide discovers target message IDs via {SearchMessages} and drills
14
+ # down here to recover the full context around any moment.
15
15
  #
16
16
  # @example
17
- # remember(message_id: 42)
18
- class Remember < Base
17
+ # view_messages(message_id: 42)
18
+ class ViewMessages < Base
19
19
  # Messages around the target to include at full resolution.
20
20
  # ±10 messages provides sharp foveal detail while keeping output readable.
21
21
  CONTEXT_WINDOW = 20
@@ -26,9 +26,9 @@ module Tools
26
26
  "system_message" => "System"
27
27
  }.freeze
28
28
 
29
- def self.tool_name = "remember"
29
+ def self.tool_name = "view_messages"
30
30
 
31
- def self.description = "Recall the full conversation around a past message."
31
+ def self.description = "View the full conversation around a message in long-term memory. Pass a message_id — typically one returned by search_messages — to see the surrounding exchange with compressed snapshots at the edges."
32
32
 
33
33
  def self.input_schema
34
34
  {
@@ -112,7 +112,7 @@ module Tools
112
112
  # @return [Array<Message>] chronologically ordered
113
113
  def fetch_center_messages(target, target_session)
114
114
  half = CONTEXT_WINDOW / 2
115
- scope = target_session.messages.context_messages
115
+ scope = target_session.messages
116
116
  target_id = target.id
117
117
 
118
118
  before = scope.where("id <= ?", target_id).reorder(id: :desc).limit(half + 1).to_a.reverse
data/lib/tools/write.rb CHANGED
@@ -19,6 +19,8 @@ module Tools
19
19
 
20
20
  def self.description = "Write file."
21
21
 
22
+ def self.prompt_snippet = "Create or overwrite a whole file."
23
+
22
24
  def self.input_schema
23
25
  {
24
26
  type: "object",