anima-core 1.0.2 → 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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.reek.yml +47 -0
  4. data/README.md +60 -26
  5. data/anima-core.gemspec +4 -1
  6. data/app/channels/session_channel.rb +29 -10
  7. data/app/decorators/tool_call_decorator.rb +7 -3
  8. data/app/decorators/tool_decorator.rb +57 -0
  9. data/app/decorators/tool_response_decorator.rb +12 -4
  10. data/app/decorators/web_get_tool_decorator.rb +102 -0
  11. data/app/jobs/agent_request_job.rb +90 -23
  12. data/app/jobs/mneme_job.rb +51 -0
  13. data/app/jobs/passive_recall_job.rb +29 -0
  14. data/app/models/concerns/event/broadcasting.rb +18 -0
  15. data/app/models/event.rb +10 -0
  16. data/app/models/goal.rb +27 -0
  17. data/app/models/goal_pinned_event.rb +11 -0
  18. data/app/models/pinned_event.rb +41 -0
  19. data/app/models/session.rb +335 -6
  20. data/app/models/snapshot.rb +76 -0
  21. data/config/initializers/event_subscribers.rb +14 -3
  22. data/config/initializers/fts5_schema_dump.rb +21 -0
  23. data/db/migrate/20260321080000_create_mneme_schema.rb +32 -0
  24. data/db/migrate/20260321120000_create_pinned_events.rb +27 -0
  25. data/db/migrate/20260321140000_create_events_fts_index.rb +77 -0
  26. data/db/migrate/20260321140100_add_recalled_event_ids_to_sessions.rb +10 -0
  27. data/lib/agent_loop.rb +63 -20
  28. data/lib/analytical_brain/runner.rb +158 -65
  29. data/lib/analytical_brain/tools/assign_nickname.rb +76 -0
  30. data/lib/analytical_brain/tools/finish_goal.rb +6 -1
  31. data/lib/anima/cli.rb +2 -1
  32. data/lib/anima/installer.rb +11 -12
  33. data/lib/anima/settings.rb +41 -0
  34. data/lib/anima/version.rb +1 -1
  35. data/lib/events/bounce_back.rb +37 -0
  36. data/lib/events/subscribers/agent_dispatcher.rb +29 -0
  37. data/lib/events/subscribers/persister.rb +17 -0
  38. data/lib/events/subscribers/subagent_message_router.rb +102 -0
  39. data/lib/events/subscribers/transient_broadcaster.rb +36 -0
  40. data/lib/llm/client.rb +16 -8
  41. data/lib/mneme/compressed_viewport.rb +200 -0
  42. data/lib/mneme/l2_runner.rb +138 -0
  43. data/lib/mneme/passive_recall.rb +69 -0
  44. data/lib/mneme/runner.rb +254 -0
  45. data/lib/mneme/search.rb +150 -0
  46. data/lib/mneme/tools/attach_events_to_goals.rb +107 -0
  47. data/lib/mneme/tools/everything_ok.rb +24 -0
  48. data/lib/mneme/tools/save_snapshot.rb +68 -0
  49. data/lib/mneme.rb +29 -0
  50. data/lib/providers/anthropic.rb +57 -13
  51. data/lib/shell_session.rb +188 -59
  52. data/lib/tasks/fts5.rake +6 -0
  53. data/lib/tools/remember.rb +179 -0
  54. data/lib/tools/spawn_specialist.rb +21 -9
  55. data/lib/tools/spawn_subagent.rb +22 -11
  56. data/lib/tools/subagent_prompts.rb +20 -3
  57. data/lib/tools/web_get.rb +15 -6
  58. data/lib/tui/app.rb +222 -125
  59. data/lib/tui/decorators/base_decorator.rb +165 -0
  60. data/lib/tui/decorators/bash_decorator.rb +20 -0
  61. data/lib/tui/decorators/edit_decorator.rb +19 -0
  62. data/lib/tui/decorators/read_decorator.rb +24 -0
  63. data/lib/tui/decorators/think_decorator.rb +36 -0
  64. data/lib/tui/decorators/web_get_decorator.rb +19 -0
  65. data/lib/tui/decorators/write_decorator.rb +19 -0
  66. data/lib/tui/flash.rb +139 -0
  67. data/lib/tui/formatting.rb +28 -0
  68. data/lib/tui/height_map.rb +93 -0
  69. data/lib/tui/message_store.rb +25 -1
  70. data/lib/tui/performance_logger.rb +90 -0
  71. data/lib/tui/screens/chat.rb +358 -133
  72. data/templates/config.toml +40 -0
  73. metadata +83 -4
  74. data/CHANGELOG.md +0 -80
  75. data/Gemfile +0 -17
  76. 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,15 +62,39 @@ 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
- # When the user interrupts, +chat_with_tools+ returns nil. Tool results
69
- # are already persisted; no agent message is emitted so the conversation
70
- # ends at the interrupted tool result.
96
+ # Lets errors propagate designed for callers like {AgentRequestJob}
97
+ # that handle retries and need errors to bubble up.
71
98
  #
72
99
  # @return [String, nil] the agent's response text, or nil when interrupted
73
100
  # @raise [Providers::Anthropic::TransientError] on retryable network/server errors
@@ -77,15 +104,15 @@ class AgentLoop
77
104
  @registry ||= build_tool_registry
78
105
 
79
106
  messages = @session.messages_for_llm
80
- options = {}
107
+ options = build_llm_options
81
108
 
82
- unless @session.sub_agent?
83
- env_context = EnvironmentProbe.to_prompt(@shell_session.pwd)
84
- end
85
- prompt = @session.system_prompt(environment_context: env_context)
86
- options[:system] = prompt if prompt
109
+ first_resp = @first_response
110
+ @first_response = nil
87
111
 
88
- response = @client.chat_with_tools(messages, registry: @registry, session_id: @session.id, **options)
112
+ response = @client.chat_with_tools(
113
+ messages, registry: @registry, session_id: @session.id,
114
+ first_response: first_resp, **options
115
+ )
89
116
  return unless response
90
117
 
91
118
  Events::Bus.emit(Events::AgentMessage.new(content: response, session_id: @session.id))
@@ -100,7 +127,7 @@ class AgentLoop
100
127
 
101
128
  # Tool classes available to all sessions by default.
102
129
  # @return [Array<Class<Tools::Base>>]
103
- STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think].freeze
130
+ STANDARD_TOOLS = [Tools::Bash, Tools::Read, Tools::Write, Tools::Edit, Tools::WebGet, Tools::Think, Tools::Remember].freeze
104
131
 
105
132
  # Name-to-class mapping for tool restriction validation and registry building.
106
133
  # @return [Hash{String => Class<Tools::Base>}]
@@ -108,10 +135,28 @@ class AgentLoop
108
135
 
109
136
  private
110
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
+
111
155
  # Builds the tool registry appropriate for this session type.
112
156
  # Main sessions get standard tools + spawn_subagent + spawn_specialist.
113
- # Sub-agent sessions get granted standard tools + return_result (no spawning).
114
- # 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}.
115
160
  # When {Session#granted_tools} is nil, all standard tools are granted.
116
161
  # MCP tools from configured servers are registered for all session types.
117
162
  #
@@ -122,9 +167,7 @@ class AgentLoop
122
167
 
123
168
  granted_standard_tools.each { |tool| registry.register(tool) }
124
169
 
125
- if @session.sub_agent?
126
- registry.register(Tools::ReturnResult)
127
- else
170
+ unless @session.sub_agent?
128
171
  registry.register(Tools::SpawnSubagent)
129
172
  registry.register(Tools::SpawnSpecialist)
130
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,13 +152,21 @@ 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
@@ -130,6 +174,15 @@ module AnalyticalBrain
130
174
  return [] if events.empty?
131
175
 
132
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,14 +216,16 @@ module AnalyticalBrain
151
216
  .reverse
152
217
  end
153
218
 
154
- # Builds the system prompt with current session state, skills catalog,
155
- # and currently active skills.
219
+ # Builds the system prompt from active responsibilities + context sections.
156
220
  #
157
221
  # @return [String]
158
222
  def build_system_prompt
159
223
  sections = [
160
- SYSTEM_PROMPT,
224
+ BASE_PROMPT,
225
+ *active_responsibilities.map(&:prompt),
226
+ COMPLETION_PROMPT,
161
227
  session_state_section,
228
+ active_siblings_section,
162
229
  skills_catalog_section,
163
230
  workflows_catalog_section,
164
231
  active_goals_section
@@ -181,6 +248,27 @@ module AnalyticalBrain
181
248
  SECTION
182
249
  end
183
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
+
184
272
  # @return [String] available skills list for the analytical brain
185
273
  def skills_catalog_section
186
274
  catalog = Skills::Registry.instance.catalog
@@ -248,11 +336,16 @@ module AnalyticalBrain
248
336
  # @return [Logger] dev-only analytical brain logger
249
337
  def log = AnalyticalBrain.logger
250
338
 
251
- # @return [Tools::Registry] registry with analytical brain tools
339
+ # @return [Tools::Registry] registry with tools from active responsibilities
252
340
  def build_registry
253
341
  registry = ::Tools::Registry.new(context: {main_session: @session})
254
- TOOLS.each { |tool| registry.register(tool) }
342
+ active_tools.each { |tool| registry.register(tool) }
255
343
  registry
256
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
257
350
  end
258
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
@@ -78,6 +78,7 @@ module Anima
78
78
 
79
79
  desc "tui", "Launch the Anima terminal interface"
80
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"
81
82
  def tui
82
83
  require "ratatui_ruby"
83
84
  require_relative "../tui/app"
@@ -89,7 +90,7 @@ module Anima
89
90
  cable_client = TUI::CableClient.new(host: host)
90
91
  cable_client.connect
91
92
 
92
- TUI::App.new(cable_client: cable_client).run
93
+ TUI::App.new(cable_client: cable_client, debug: options[:debug]).run
93
94
  end
94
95
 
95
96
  desc "version", "Show version"
@@ -115,19 +115,22 @@ module Anima
115
115
 
116
116
  next if key_path.exist? && content_path.exist?
117
117
 
118
+ content_str = content_path.to_s
119
+ key_str = key_path.to_s
120
+
118
121
  key = ActiveSupport::EncryptedFile.generate_key
119
122
  key_path.write(key)
120
- File.chmod(0o600, key_path.to_s)
123
+ File.chmod(0o600, key_str)
121
124
 
122
125
  config = ActiveSupport::EncryptedConfiguration.new(
123
- config_path: content_path.to_s,
124
- key_path: key_path.to_s,
126
+ config_path: content_str,
127
+ key_path: key_str,
125
128
  env_key: "RAILS_MASTER_KEY",
126
129
  raise_if_missing_key: true
127
130
  )
128
131
 
129
132
  config.write("secret_key_base: #{SecureRandom.hex(64)}\n")
130
- File.chmod(0o600, content_path.to_s)
133
+ File.chmod(0o600, content_str)
131
134
  say " created credentials for #{env}"
132
135
  end
133
136
  end
@@ -153,16 +156,12 @@ module Anima
153
156
  WantedBy=default.target
154
157
  UNIT
155
158
 
156
- if service_path.exist?
157
- if service_path.read == unit_content
158
- say " anima.service unchanged"
159
- else
160
- service_path.write(unit_content)
161
- say " updated #{service_path}"
162
- end
159
+ already_exists = service_path.exist?
160
+ if already_exists && service_path.read == unit_content
161
+ say " anima.service unchanged"
163
162
  else
164
163
  service_path.write(unit_content)
165
- say " created #{service_path}"
164
+ say " #{already_exists ? "updated" : "created"} #{service_path}"
166
165
  end
167
166
 
168
167
  system("systemctl", "--user", "daemon-reload", err: File::NULL, out: File::NULL)
@@ -177,6 +177,47 @@ module Anima
177
177
  # @return [Integer]
178
178
  def analytical_brain_event_window = get("analytical_brain", "event_window")
179
179
 
180
+ # ─── Mneme (Memory Department) ────────────────────────────────
181
+
182
+ # Maximum tokens per Mneme LLM response.
183
+ # @return [Integer]
184
+ def mneme_max_tokens = get("mneme", "max_tokens")
185
+
186
+ # Fraction of the main viewport token budget allocated to Mneme's viewport.
187
+ # @return [Float]
188
+ def mneme_viewport_fraction = get("mneme", "viewport_fraction")
189
+
190
+ # Fraction of the main viewport token budget reserved for L1 snapshots.
191
+ # @return [Float]
192
+ def mneme_l1_budget_fraction = get("mneme", "l1_budget_fraction")
193
+
194
+ # Fraction of the main viewport token budget reserved for L2 snapshots.
195
+ # @return [Float]
196
+ def mneme_l2_budget_fraction = get("mneme", "l2_budget_fraction")
197
+
198
+ # Number of uncovered L1 snapshots that triggers L2 compression.
199
+ # @return [Integer]
200
+ def mneme_l2_snapshot_threshold = get("mneme", "l2_snapshot_threshold")
201
+
202
+ # Fraction of the main viewport token budget reserved for pinned events.
203
+ # Pinned events appear between snapshots and the sliding window.
204
+ # @return [Float]
205
+ def mneme_pinned_budget_fraction = get("mneme", "pinned_budget_fraction")
206
+
207
+ # ─── Recall (Associative Memory) ────────────────────────────
208
+
209
+ # Maximum search results returned per FTS5 query.
210
+ # @return [Integer]
211
+ def recall_max_results = get("recall", "max_results")
212
+
213
+ # Fraction of the main viewport token budget reserved for recalled memories.
214
+ # @return [Float]
215
+ def recall_budget_fraction = get("recall", "budget_fraction")
216
+
217
+ # Maximum tokens per individual recall snippet.
218
+ # @return [Integer]
219
+ def recall_max_snippet_tokens = get("recall", "max_snippet_tokens")
220
+
180
221
  private
181
222
 
182
223
  # Reads a setting from the config file.
data/lib/anima/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Anima
4
- VERSION = "1.0.2"
4
+ VERSION = "1.1.0"
5
5
  end