anima-core 1.0.1 → 1.1.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +61 -0
  4. data/README.md +202 -116
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +44 -10
  7. data/app/decorators/agent_message_decorator.rb +6 -0
  8. data/app/decorators/event_decorator.rb +41 -7
  9. data/app/decorators/tool_call_decorator.rb +66 -5
  10. data/app/decorators/tool_decorator.rb +57 -0
  11. data/app/decorators/tool_response_decorator.rb +35 -5
  12. data/app/decorators/user_message_decorator.rb +6 -0
  13. data/app/decorators/web_get_tool_decorator.rb +102 -0
  14. data/app/jobs/agent_request_job.rb +95 -20
  15. data/app/jobs/mneme_job.rb +51 -0
  16. data/app/jobs/passive_recall_job.rb +29 -0
  17. data/app/models/concerns/event/broadcasting.rb +18 -0
  18. data/app/models/event.rb +10 -0
  19. data/app/models/goal.rb +27 -0
  20. data/app/models/goal_pinned_event.rb +11 -0
  21. data/app/models/pinned_event.rb +41 -0
  22. data/app/models/session.rb +335 -6
  23. data/app/models/snapshot.rb +76 -0
  24. data/config/initializers/event_subscribers.rb +14 -3
  25. data/config/initializers/fts5_schema_dump.rb +21 -0
  26. data/db/migrate/20260316094817_add_interrupt_requested_to_sessions.rb +5 -0
  27. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  28. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  29. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  30. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  31. data/lib/agent_loop.rb +67 -18
  32. data/lib/analytical_brain/runner.rb +159 -84
  33. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  34. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  35. data/lib/anima/cli.rb +34 -1
  36. data/lib/anima/config_migrator.rb +205 -0
  37. data/lib/anima/installer.rb +13 -130
  38. data/lib/anima/settings.rb +42 -1
  39. data/lib/anima/version.rb +1 -1
  40. data/lib/events/bounce_back.rb +37 -0
  41. data/lib/events/subscribers/agent_dispatcher.rb +29 -0
  42. data/lib/events/subscribers/persister.rb +17 -0
  43. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  44. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  45. data/lib/llm/client.rb +99 -14
  46. data/lib/mneme/compressed_viewport.rb +200 -0
  47. data/lib/mneme/l2_runner.rb +138 -0
  48. data/lib/mneme/passive_recall.rb +69 -0
  49. data/lib/mneme/runner.rb +254 -0
  50. data/lib/mneme/search.rb +150 -0
  51. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  52. data/lib/mneme/tools/everything_ok.rb +24 -0
  53. data/lib/mneme/tools/save_snapshot.rb +68 -0
  54. data/lib/mneme.rb +29 -0
  55. data/lib/providers/anthropic.rb +57 -13
  56. data/lib/shell_session.rb +188 -59
  57. data/lib/tasks/fts5.rake +6 -0
  58. data/lib/tools/remember.rb +179 -0
  59. data/lib/tools/spawn_specialist.rb +21 -9
  60. data/lib/tools/spawn_subagent.rb +22 -11
  61. data/lib/tools/subagent_prompts.rb +20 -3
  62. data/lib/tools/think.rb +57 -0
  63. data/lib/tools/web_get.rb +15 -6
  64. data/lib/tui/app.rb +230 -127
  65. data/lib/tui/cable_client.rb +8 -0
  66. data/lib/tui/decorators/base_decorator.rb +165 -0
  67. data/lib/tui/decorators/bash_decorator.rb +20 -0
  68. data/lib/tui/decorators/edit_decorator.rb +19 -0
  69. data/lib/tui/decorators/read_decorator.rb +24 -0
  70. data/lib/tui/decorators/think_decorator.rb +36 -0
  71. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  72. data/lib/tui/decorators/write_decorator.rb +19 -0
  73. data/lib/tui/flash.rb +139 -0
  74. data/lib/tui/formatting.rb +28 -0
  75. data/lib/tui/height_map.rb +93 -0
  76. data/lib/tui/message_store.rb +25 -1
  77. data/lib/tui/performance_logger.rb +90 -0
  78. data/lib/tui/screens/chat.rb +374 -109
  79. data/templates/config.toml +156 -0
  80. metadata +87 -4
  81. data/CHANGELOG.md +0 -79
  82. data/Gemfile +0 -17
  83. data/lib/tools/return_result.rb +0 -81
data/lib/agent_loop.rb CHANGED
@@ -42,7 +42,9 @@ class AgentLoop
42
42
 
43
43
  # Runs the agent loop for a single user input.
44
44
  #
45
- # Emits {Events::UserMessage} immediately, then delegates to {#run}.
45
+ # Persists the user event directly (the global Persister skips
46
+ # non-pending user messages because {AgentRequestJob} owns their
47
+ # lifecycle). Then emits a bus notification and delegates to {#run}.
46
48
  # On error emits {Events::AgentMessage} with the error text.
47
49
  #
48
50
  # @param input [String] raw user input
@@ -51,6 +53,7 @@ class AgentLoop
51
53
  text = input.to_s.strip
52
54
  return if text.empty?
53
55
 
56
+ persist_user_event(text)
54
57
  Events::Bus.emit(Events::UserMessage.new(content: text, session_id: @session.id))
55
58
  run
56
59
  rescue => error
@@ -59,13 +62,41 @@ class AgentLoop
59
62
  error_message
60
63
  end
61
64
 
65
+ # Makes the first LLM API call to verify delivery. Called inside the
66
+ # Bounce Back transaction — if this raises, the user event rolls back.
67
+ #
68
+ # Caches the first response so the subsequent {#run} call can continue
69
+ # from it without duplicating the API call.
70
+ #
71
+ # @return [void]
72
+ # @raise [Providers::Anthropic::Error] on any LLM delivery failure
73
+ def deliver!
74
+ @client ||= LLM::Client.new
75
+ @registry ||= build_tool_registry
76
+
77
+ messages = @session.messages_for_llm
78
+ options = build_llm_options
79
+
80
+ @first_response = @client.provider.create_message(
81
+ model: @client.model,
82
+ messages: messages,
83
+ max_tokens: @client.max_tokens,
84
+ tools: @registry.schemas,
85
+ **options
86
+ )
87
+ end
88
+
62
89
  # Runs the LLM tool-use loop on persisted session messages.
63
90
  #
64
- # Unlike {#process}, does not emit {Events::UserMessage} and lets errors
65
- # propagate designed for callers like {AgentRequestJob} that handle
66
- # retries and need errors to bubble up.
91
+ # When a cached first response exists (from {#deliver!}), continues
92
+ # from that response without a redundant API call. Otherwise makes
93
+ # a fresh call used for pending message processing and the standard
94
+ # path.
67
95
  #
68
- # @return [String] the agent's response text
96
+ # Lets errors propagate designed for callers like {AgentRequestJob}
97
+ # that handle retries and need errors to bubble up.
98
+ #
99
+ # @return [String, nil] the agent's response text, or nil when interrupted
69
100
  # @raise [Providers::Anthropic::TransientError] on retryable network/server errors
70
101
  # @raise [Providers::Anthropic::AuthenticationError] on auth failures
71
102
  def run
@@ -73,15 +104,17 @@ class AgentLoop
73
104
  @registry ||= build_tool_registry
74
105
 
75
106
  messages = @session.messages_for_llm
76
- options = {}
107
+ options = build_llm_options
77
108
 
78
- unless @session.sub_agent?
79
- env_context = EnvironmentProbe.to_prompt(@shell_session.pwd)
80
- end
81
- prompt = @session.system_prompt(environment_context: env_context)
82
- options[:system] = prompt if prompt
109
+ first_resp = @first_response
110
+ @first_response = nil
111
+
112
+ response = @client.chat_with_tools(
113
+ messages, registry: @registry, session_id: @session.id,
114
+ first_response: first_resp, **options
115
+ )
116
+ return unless response
83
117
 
84
- response = @client.chat_with_tools(messages, registry: @registry, session_id: @session.id, **options)
85
118
  Events::Bus.emit(Events::AgentMessage.new(content: response, session_id: @session.id))
86
119
  response
87
120
  end
@@ -94,7 +127,7 @@ class AgentLoop
94
127
 
95
128
  # Tool classes available to all sessions by default.
96
129
  # @return [Array<Class<Tools::Base>>]
97
- STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet].freeze
130
+ STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember].freeze
98
131
 
99
132
  # Name-to-class mapping for tool restriction validation and registry building.
100
133
  # @return [Hash{String => Class<Tools::Base>}]
@@ -102,10 +135,28 @@ class AgentLoop
102
135
 
103
136
  private
104
137
 
138
+ # @see Session#create_user_event
139
+ def persist_user_event(content)
140
+ @session.create_user_event(content)
141
+ end
142
+
143
+ # Assembles LLM options (system prompt, environment context).
144
+ # @return [Hash] options for {LLM::Client#chat_with_tools}
145
+ def build_llm_options
146
+ options = {}
147
+ unless @session.sub_agent?
148
+ env_context = EnvironmentProbe.to_prompt(@shell_session.pwd)
149
+ end
150
+ prompt = @session.system_prompt(environment_context: env_context)
151
+ options[:system] = prompt if prompt
152
+ options
153
+ end
154
+
105
155
  # Builds the tool registry appropriate for this session type.
106
156
  # Main sessions get standard tools + spawn_subagent + spawn_specialist.
107
- # Sub-agent sessions get granted standard tools + return_result (no spawning).
108
- # Sub-agents cannot spawn further sub-agents (no recursive nesting).
157
+ # Sub-agents get granted standard tools only (no spawning, no nesting).
158
+ # Sub-agent results are delivered through natural text messages routed
159
+ # by {Events::Subscribers::SubagentMessageRouter}.
109
160
  # When {Session#granted_tools} is nil, all standard tools are granted.
110
161
  # MCP tools from configured servers are registered for all session types.
111
162
  #
@@ -116,9 +167,7 @@ class AgentLoop
116
167
 
117
168
  granted_standard_tools.each { |tool| registry.register(tool) }
118
169
 
119
- if @session.sub_agent?
120
- registry.register(Tools::ReturnResult)
121
- else
170
+ unless @session.sub_agent?
122
171
  registry.register(Tools::SpawnSubagent)
123
172
  registry.register(Tools::SpawnSpecialist)
124
173
  registry.register(Tools::RequestFeature)
@@ -2,67 +2,99 @@
2
2
 
3
3
  module AnalyticalBrain
4
4
  # Orchestrates the analytical brain — a phantom (non-persisted) LLM loop
5
- # that observes a main session and performs background maintenance via tools.
5
+ # that observes a session and performs background maintenance via tools.
6
6
  #
7
- # The analytical brain is a "subconscious" process: it operates ON the main
8
- # session without the main agent knowing it exists. Tools mutate the main
9
- # session directly (e.g. renaming it, activating skills), but no trace of
10
- # the analytical brain's reasoning is persisted.
7
+ # The brain's capabilities are assembled from independent {Responsibility}
8
+ # modules, each contributing a prompt section and tools. Which modules are
9
+ # active depends on the session type:
10
+ #
11
+ # * **Parent sessions** — session naming, skill/workflow/goal management
12
+ # * **Child sessions** — sub-agent nickname assignment, skill/workflow/goal management
13
+ #
14
+ # Tools mutate the observed session directly (e.g. renaming it, activating
15
+ # skills), but no trace of the brain's reasoning is persisted — events are
16
+ # emitted into a phantom session (session_id: nil).
11
17
  #
12
18
  # @example
13
19
  # AnalyticalBrain::Runner.new(session).call
14
20
  class Runner
15
- # Tools available to the analytical brain.
16
- # @return [Array<Class<Tools::Base>>]
17
- TOOLS = [
18
- Tools::RenameSession,
19
- Tools::ActivateSkill,
20
- Tools::DeactivateSkill,
21
- Tools::ReadWorkflow,
22
- Tools::DeactivateWorkflow,
23
- Tools::SetGoal,
24
- Tools::UpdateGoal,
25
- Tools::FinishGoal,
26
- Tools::EverythingIsReady
27
- ].freeze
28
-
29
- SYSTEM_PROMPT = <<~PROMPT
30
- You are a background automation that manages session metadata.
31
- You MUST ONLY communicate through tool calls — NEVER output text.
32
- Always finish by calling everything_is_ready.
21
+ # A composable unit of brain capability: a prompt section + its tools.
22
+ Responsibility = Data.define(:prompt, :tools)
33
23
 
34
- ──────────────────────────────
35
- SESSION NAMING
36
- ──────────────────────────────
37
- Call rename_session when the topic becomes clear or shifts.
38
- Format: one emoji + 1-3 descriptive words.
24
+ RESPONSIBILITIES = {
25
+ session_naming: Responsibility.new(
26
+ prompt: <<~PROMPT,
27
+ ──────────────────────────────
28
+ SESSION NAMING
29
+ ──────────────────────────────
30
+ Call rename_session when the topic becomes clear or shifts.
31
+ Format: one emoji + 1-3 descriptive words.
32
+ PROMPT
33
+ tools: [Tools::RenameSession]
34
+ ),
39
35
 
40
- ──────────────────────────────
41
- SKILL MANAGEMENT
42
- ──────────────────────────────
43
- Call activate_skill when the conversation matches a skill's description.
44
- Call deactivate_skill when the agent moves to a different domain.
45
- Multiple skills can be active at once.
36
+ sub_agent_naming: Responsibility.new(
37
+ prompt: <<~PROMPT,
38
+ ──────────────────────────────
39
+ SUB-AGENT NAMING
40
+ ──────────────────────────────
41
+ Call assign_nickname to give this sub-agent a short, memorable nickname.
42
+ Format: 1-3 lowercase words joined by hyphens (e.g. "loop-sleuth", "api-scout").
43
+ Evocative of the task, fun, easy to type after @.
44
+ Generate EXACTLY ONE nickname. If taken, pick another — no numeric suffixes.
45
+ PROMPT
46
+ tools: [Tools::AssignNickname]
47
+ ),
46
48
 
47
- ──────────────────────────────
48
- WORKFLOW MANAGEMENT
49
- ──────────────────────────────
50
- Call read_workflow when the user starts a multi-step task matching a workflow description.
51
- Read the returned content and use judgment to create appropriate goals — not a mechanical 1:1 mapping.
52
- Adapt to context: skip irrelevant steps, add extra steps for unfamiliar areas.
53
- Call deactivate_workflow when the workflow completes or the user shifts focus.
54
- Only one workflow can be active at a time — activating a new one replaces the previous.
49
+ skill_management: Responsibility.new(
50
+ prompt: <<~PROMPT,
51
+ ──────────────────────────────
52
+ SKILL MANAGEMENT
53
+ ──────────────────────────────
54
+ Call activate_skill when the conversation matches a skill's description.
55
+ Call deactivate_skill when the agent moves to a different domain.
56
+ Multiple skills can be active at once.
57
+ PROMPT
58
+ tools: [Tools::ActivateSkill, Tools::DeactivateSkill]
59
+ ),
55
60
 
56
- ──────────────────────────────
57
- GOAL TRACKING
58
- ──────────────────────────────
59
- Call set_goal to create a root goal when the user starts a multi-step task.
60
- Call set_goal with parent_goal_id to add sub-goals (TODO items) under it.
61
- Call update_goal to refine a goal's description as understanding evolves.
62
- Call finish_goal when the main agent completes work a goal describes.
63
- Finishing a root goal cascades all active sub-goals are completed too.
64
- Never duplicate an existing goal check the active goals list first.
61
+ workflow_management: Responsibility.new(
62
+ prompt: <<~PROMPT,
63
+ ──────────────────────────────
64
+ WORKFLOW MANAGEMENT
65
+ ──────────────────────────────
66
+ Call read_workflow when the user starts a multi-step task matching a workflow description.
67
+ Read the returned content and use judgment to create appropriate goals — not a mechanical 1:1 mapping.
68
+ Adapt to context: skip irrelevant steps, add extra steps for unfamiliar areas.
69
+ Call deactivate_workflow when the workflow completes or the user shifts focus.
70
+ Only one workflow can be active at a time — activating a new one replaces the previous.
71
+ PROMPT
72
+ tools: [Tools::ReadWorkflow, Tools::DeactivateWorkflow]
73
+ ),
65
74
 
75
+ goal_tracking: Responsibility.new(
76
+ prompt: <<~PROMPT,
77
+ ──────────────────────────────
78
+ GOAL TRACKING
79
+ ──────────────────────────────
80
+ Call set_goal to create a root goal when the user starts a multi-step task.
81
+ Call set_goal with parent_goal_id to add sub-goals (TODO items) under it.
82
+ Call update_goal to refine a goal's description as understanding evolves.
83
+ Call finish_goal when the main agent completes work a goal describes.
84
+ Finishing a root goal cascades — all active sub-goals are completed too.
85
+ Never duplicate an existing goal — check the active goals list first.
86
+ PROMPT
87
+ tools: [Tools::SetGoal, Tools::UpdateGoal, Tools::FinishGoal]
88
+ )
89
+ }.freeze
90
+
91
+ BASE_PROMPT = <<~PROMPT
92
+ You are a background automation that manages session metadata.
93
+ You MUST ONLY communicate through tool calls — NEVER output text.
94
+ Always finish by calling everything_is_ready.
95
+ PROMPT
96
+
97
+ COMPLETION_PROMPT = <<~PROMPT
66
98
  ──────────────────────────────
67
99
  COMPLETION
68
100
  ──────────────────────────────
@@ -70,7 +102,11 @@ module AnalyticalBrain
70
102
  If nothing needs changing, call it immediately as your only tool call.
71
103
  PROMPT
72
104
 
73
- # @param session [Session] the main session to observe and maintain
105
+ # Which responsibilities activate for each session type.
106
+ PARENT_RESPONSIBILITIES = %i[session_naming skill_management workflow_management goal_tracking].freeze
107
+ CHILD_RESPONSIBILITIES = %i[sub_agent_naming skill_management workflow_management goal_tracking].freeze
108
+
109
+ # @param session [Session] the session to observe and maintain
74
110
  # @param client [LLM::Client, nil] injectable LLM client (defaults to fast model)
75
111
  def initialize(session, client: nil)
76
112
  @session = session
@@ -81,9 +117,9 @@ module AnalyticalBrain
81
117
  )
82
118
  end
83
119
 
84
- # Runs the analytical brain loop. Builds context from the main session's
85
- # recent events, calls the LLM with the analytical brain's tool set, and
86
- # executes any tool calls against the main session.
120
+ # Runs the analytical brain loop. Builds context from the session's
121
+ # recent events, calls the LLM with the session-appropriate tool set,
122
+ # and executes any tool calls against the session.
87
123
  #
88
124
  # Events emitted during tool execution are not persisted — the phantom
89
125
  # session_id (nil) causes the global Persister to skip them.
@@ -116,20 +152,37 @@ module AnalyticalBrain
116
152
 
117
153
  private
118
154
 
155
+ # @return [Array<Symbol>] responsibility keys for this session type
156
+ def active_responsibility_keys
157
+ @session.sub_agent? ? CHILD_RESPONSIBILITIES : PARENT_RESPONSIBILITIES
158
+ end
159
+
160
+ # @return [Array<Responsibility>] active responsibility modules
161
+ def active_responsibilities
162
+ active_responsibility_keys.map { |key| RESPONSIBILITIES.fetch(key) }
163
+ end
164
+
119
165
  # Builds a condensed transcript of recent events as a single user message.
120
- # The analytical brain doesn't need multi-turn conversation history — it
121
- # just needs to understand "what is the agent doing RIGHT NOW?"
166
+ # The framing differs by session type:
122
167
  #
123
- # The transcript is framed as an observation of the main session, not as
124
- # a direct message to the analytical brain. Without this framing, Haiku
125
- # confuses the main session's user messages with requests directed at it.
168
+ # * **Parent:** "The main session is working on this: [transcript]"
169
+ # * **Child:** "A sub-agent has been spawned with this task: [transcript]"
126
170
  #
127
171
  # @return [Array<Hash>] single-element messages array, or empty if no events
128
172
  def build_messages
129
173
  events = recent_events
130
174
  return [] if events.empty?
131
175
 
132
- transcript = events.filter_map { |event| format_event(event) }.join("\n")
176
+ transcript = events.filter_map { |event| EventDecorator.for(event)&.render("brain") }.join("\n")
177
+
178
+ if @session.sub_agent?
179
+ build_child_message(transcript)
180
+ else
181
+ build_parent_message(transcript)
182
+ end
183
+ end
184
+
185
+ def build_parent_message(transcript)
133
186
  content = <<~MSG.strip
134
187
  The main session is working on this:
135
188
  ```
@@ -141,6 +194,18 @@ module AnalyticalBrain
141
194
  [{role: "user", content: content}]
142
195
  end
143
196
 
197
+ def build_child_message(transcript)
198
+ content = <<~MSG.strip
199
+ A sub-agent has been spawned with this task:
200
+ ```
201
+ #{transcript}
202
+ ```
203
+
204
+ Assign a memorable nickname based on the task, activate relevant skills, then call everything_is_ready.
205
+ MSG
206
+ [{role: "user", content: content}]
207
+ end
208
+
144
209
  # @return [Array<Event>] most recent events in chronological order
145
210
  def recent_events
146
211
  @session.events
@@ -151,32 +216,16 @@ module AnalyticalBrain
151
216
  .reverse
152
217
  end
153
218
 
154
- # Formats a single event for the analytical brain's transcript.
155
- # User/agent messages get 500 chars to preserve conversation context;
156
- # tool responses get 200 chars to reduce noise from verbose outputs.
157
- #
158
- # @param event [Event]
159
- # @return [String, nil] formatted line, or nil for unhandled event types
160
- def format_event(event)
161
- payload = event.payload
162
- summary = payload["content"].to_s.truncate(500)
163
-
164
- case event.event_type
165
- when "user_message" then "User: #{summary}"
166
- when "agent_message" then "Assistant: #{summary}"
167
- when "tool_call" then "Tool call: #{payload["tool_name"]}"
168
- when "tool_response" then "Tool result: #{summary.truncate(200)}"
169
- end
170
- end
171
-
172
- # Builds the system prompt with current session state, skills catalog,
173
- # and currently active skills.
219
+ # Builds the system prompt from active responsibilities + context sections.
174
220
  #
175
221
  # @return [String]
176
222
  def build_system_prompt
177
223
  sections = [
178
- SYSTEM_PROMPT,
224
+ BASE_PROMPT,
225
+ *active_responsibilities.map(&:prompt),
226
+ COMPLETION_PROMPT,
179
227
  session_state_section,
228
+ active_siblings_section,
180
229
  skills_catalog_section,
181
230
  workflows_catalog_section,
182
231
  active_goals_section
@@ -199,6 +248,27 @@ module AnalyticalBrain
199
248
  SECTION
200
249
  end
201
250
 
251
+ # Shows sibling nicknames already in use so the brain avoids collisions
252
+ # at prompt level (the tool also validates at execution time).
253
+ #
254
+ # @return [String, nil] sibling names section, or nil for parent sessions
255
+ def active_siblings_section
256
+ return unless @session.sub_agent?
257
+
258
+ siblings = @session.parent_session.child_sessions
259
+ .where.not(id: @session.id)
260
+ .where.not(name: nil)
261
+ .pluck(:name)
262
+ return if siblings.empty?
263
+
264
+ <<~SECTION
265
+ ──────────────────────────────
266
+ ACTIVE SIBLINGS
267
+ ──────────────────────────────
268
+ These nicknames are already taken: #{siblings.join(", ")}
269
+ SECTION
270
+ end
271
+
202
272
  # @return [String] available skills list for the analytical brain
203
273
  def skills_catalog_section
204
274
  catalog = Skills::Registry.instance.catalog
@@ -266,11 +336,16 @@ module AnalyticalBrain
266
336
  # @return [Logger] dev-only analytical brain logger
267
337
  def log = AnalyticalBrain.logger
268
338
 
269
- # @return [Tools::Registry] registry with analytical brain tools
339
+ # @return [Tools::Registry] registry with tools from active responsibilities
270
340
  def build_registry
271
341
  registry = ::Tools::Registry.new(context: {main_session: @session})
272
- TOOLS.each { |tool| registry.register(tool) }
342
+ active_tools.each { |tool| registry.register(tool) }
273
343
  registry
274
344
  end
345
+
346
+ # @return [Array<Class<Tools::Base>>] tools from all active responsibilities + completion
347
+ def active_tools
348
+ active_responsibilities.flat_map(&:tools) + [Tools::EverythingIsReady]
349
+ end
275
350
  end
276
351
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticalBrain
4
+ module Tools
5
+ # Assigns a static nickname to a sub-agent session.
6
+ # Operates on the session passed through the registry context.
7
+ #
8
+ # Nicknames must be unique among active siblings — the tool returns
9
+ # an error on collision so the LLM can pick another name naturally,
10
+ # without programmatic suffixes.
11
+ #
12
+ # @see AnalyticalBrain::Runner — invokes this tool for child sessions
13
+ class AssignNickname < ::Tools::Base
14
+ # Lowercase hyphenated words: "loop-sleuth", "api-scout", "test-fixer"
15
+ NICKNAME_PATTERN = /\A[a-z][a-z0-9]*(-[a-z0-9]+)*\z/
16
+ MAX_LENGTH = 30
17
+
18
+ def self.tool_name = "assign_nickname"
19
+
20
+ def self.description = "Assign a short, memorable nickname to this sub-agent. " \
21
+ "The nickname is permanent — it will not change."
22
+
23
+ def self.input_schema
24
+ {
25
+ type: "object",
26
+ properties: {
27
+ nickname: {
28
+ type: "string",
29
+ description: "1-3 lowercase words joined by hyphens (e.g. 'loop-sleuth', 'api-scout'). " \
30
+ "Evocative of the task, fun, easy to type after @."
31
+ }
32
+ },
33
+ required: %w[nickname]
34
+ }
35
+ end
36
+
37
+ # @param main_session [Session] the sub-agent session to name
38
+ def initialize(main_session:, **)
39
+ @session = main_session
40
+ end
41
+
42
+ # @param input [Hash<String, Object>] with "nickname" key
43
+ # @return [String] confirmation message
44
+ # @return [Hash] with :error key on validation failure
45
+ def execute(input)
46
+ nickname = input["nickname"].to_s.strip.downcase
47
+
48
+ error = validate(nickname)
49
+ return error if error
50
+
51
+ @session.update!(name: nickname)
52
+ "Nickname set to @#{nickname}"
53
+ end
54
+
55
+ private
56
+
57
+ def validate(nickname)
58
+ return {error: "Nickname cannot be blank"} if nickname.empty?
59
+ return {error: "Invalid format: use 1-3 lowercase words joined by hyphens"} unless nickname.match?(NICKNAME_PATTERN)
60
+ return {error: "Nickname too long (max #{MAX_LENGTH} chars)"} if nickname.length > MAX_LENGTH
61
+
62
+ if sibling_nickname_taken?(nickname)
63
+ {error: "Nickname '#{nickname}' is already taken by a sibling. Choose another."}
64
+ end
65
+ end
66
+
67
+ def sibling_nickname_taken?(nickname)
68
+ return false unless @session.parent_session
69
+
70
+ @session.parent_session.child_sessions
71
+ .where.not(id: @session.id)
72
+ .exists?(name: nickname)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -51,11 +51,16 @@ module AnalyticalBrain
51
51
  id = goal.id
52
52
  return {error: "Goal already completed: #{goal.description} (id: #{id})"} if goal.completed?
53
53
 
54
+ released = 0
54
55
  Goal.transaction do
55
56
  goal.update!(status: "completed", completed_at: Time.current)
56
57
  goal.cascade_completion! if goal.root?
58
+ released = goal.release_orphaned_pins!
57
59
  end
58
- "Goal completed: #{goal.description} (id: #{id})"
60
+
61
+ msg = "Goal completed: #{goal.description} (id: #{id})"
62
+ msg += " (released #{released} orphaned pins)" if released > 0
63
+ msg
59
64
  end
60
65
  end
61
66
  end
data/lib/anima/cli.rb CHANGED
@@ -20,6 +20,38 @@ module Anima
20
20
  Installer.new.run
21
21
  end
22
22
 
23
+ desc "update", "Upgrade gem and migrate config"
24
+ option :migrate_only, type: :boolean, default: false, desc: "Skip gem upgrade, only migrate config"
25
+ def update
26
+ unless options[:migrate_only]
27
+ say "Upgrading anima-core gem..."
28
+ unless system("gem", "update", "anima-core")
29
+ say "Gem update failed.", :red
30
+ exit 1
31
+ end
32
+
33
+ # Re-exec with the updated gem so migration uses the new template.
34
+ exec(File.join(Gem.bindir, "anima"), "update", "--migrate-only")
35
+ end
36
+
37
+ say "Migrating configuration..."
38
+ require_relative "config_migrator"
39
+ result = Anima::ConfigMigrator.new.run
40
+
41
+ case result.status
42
+ when :not_found
43
+ say "Config file not found. Run 'anima install' first.", :red
44
+ exit 1
45
+ when :up_to_date
46
+ say "Config is already up to date."
47
+ when :updated
48
+ result.additions.each do |addition|
49
+ say " added [#{addition.section}] #{addition.key} = #{addition.value.inspect}"
50
+ end
51
+ say "Config updated. Changes take effect immediately — no restart needed."
52
+ end
53
+ end
54
+
23
55
  # Start the Anima brain server (Puma + Solid Queue) via Foreman.
24
56
  # Environment precedence: -e flag > RAILS_ENV env var > "development".
25
57
  # Requires prior installation (~/.anima must exist).
@@ -46,6 +78,7 @@ module Anima
46
78
 
47
79
  desc "tui", "Launch the Anima terminal interface"
48
80
  option :host, desc: "Brain server address (default: #{DEFAULT_HOST})"
81
+ option :debug, type: :boolean, default: false, desc: "Enable performance logging to log/tui_performance.log"
49
82
  def tui
50
83
  require "ratatui_ruby"
51
84
  require_relative "../tui/app"
@@ -57,7 +90,7 @@ module Anima
57
90
  cable_client = TUI::CableClient.new(host: host)
58
91
  cable_client.connect
59
92
 
60
- TUI::App.new(cable_client: cable_client).run
93
+ TUI::App.new(cable_client: cable_client, debug: options[:debug]).run
61
94
  end
62
95
 
63
96
  desc "version", "Show version"