anima-core 0.2.1 → 1.0.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 (280) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +27 -1
  3. data/CHANGELOG.md +19 -0
  4. data/README.md +213 -43
  5. data/agents/codebase-analyzer.md +88 -0
  6. data/agents/codebase-pattern-finder.md +83 -0
  7. data/agents/documentation-researcher.md +59 -0
  8. data/agents/thoughts-analyzer.md +102 -0
  9. data/agents/web-search-researcher.md +71 -0
  10. data/anima-core.gemspec +3 -0
  11. data/app/channels/session_channel.rb +195 -45
  12. data/app/decorators/user_message_decorator.rb +16 -5
  13. data/app/jobs/agent_request_job.rb +55 -2
  14. data/app/jobs/analytical_brain_job.rb +33 -0
  15. data/app/jobs/count_event_tokens_job.rb +15 -4
  16. data/app/models/concerns/event/broadcasting.rb +81 -0
  17. data/app/models/event.rb +20 -1
  18. data/app/models/goal.rb +91 -0
  19. data/app/models/session.rb +366 -21
  20. data/config/application.rb +2 -0
  21. data/config/initializers/event_subscribers.rb +0 -1
  22. data/config/routes.rb +0 -6
  23. data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
  24. data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
  25. data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
  26. data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
  27. data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
  28. data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
  29. data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
  30. data/db/migrate/20260315140843_create_goals.rb +16 -0
  31. data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
  32. data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
  33. data/lib/agent_loop.rb +65 -6
  34. data/lib/agents/definition.rb +116 -0
  35. data/lib/agents/registry.rb +106 -0
  36. data/lib/analytical_brain/runner.rb +276 -0
  37. data/lib/analytical_brain/tools/activate_skill.rb +52 -0
  38. data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
  39. data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
  40. data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
  41. data/lib/analytical_brain/tools/finish_goal.rb +62 -0
  42. data/lib/analytical_brain/tools/read_workflow.rb +58 -0
  43. data/lib/analytical_brain/tools/rename_session.rb +63 -0
  44. data/lib/analytical_brain/tools/set_goal.rb +60 -0
  45. data/lib/analytical_brain/tools/update_goal.rb +60 -0
  46. data/lib/analytical_brain.rb +23 -0
  47. data/lib/anima/cli/mcp/secrets.rb +76 -0
  48. data/lib/anima/cli/mcp.rb +197 -0
  49. data/lib/anima/cli.rb +5 -40
  50. data/lib/anima/installer.rb +168 -0
  51. data/lib/anima/settings.rb +226 -0
  52. data/lib/anima/version.rb +1 -1
  53. data/lib/anima.rb +9 -0
  54. data/lib/credential_store.rb +103 -0
  55. data/lib/environment_probe.rb +232 -0
  56. data/lib/events/subscribers/persister.rb +1 -0
  57. data/lib/events/user_message.rb +17 -0
  58. data/lib/llm/client.rb +29 -10
  59. data/lib/mcp/client_manager.rb +86 -0
  60. data/lib/mcp/config.rb +213 -0
  61. data/lib/mcp/health_check.rb +77 -0
  62. data/lib/mcp/secrets.rb +73 -0
  63. data/lib/mcp/stdio_transport.rb +206 -0
  64. data/lib/providers/anthropic.rb +11 -20
  65. data/lib/shell_session.rb +11 -10
  66. data/lib/skills/definition.rb +97 -0
  67. data/lib/skills/registry.rb +105 -0
  68. data/lib/tools/edit.rb +226 -0
  69. data/lib/tools/mcp_tool.rb +114 -0
  70. data/lib/tools/read.rb +151 -0
  71. data/lib/tools/registry.rb +14 -12
  72. data/lib/tools/request_feature.rb +121 -0
  73. data/lib/tools/return_result.rb +81 -0
  74. data/lib/tools/spawn_specialist.rb +109 -0
  75. data/lib/tools/spawn_subagent.rb +111 -0
  76. data/lib/tools/subagent_prompts.rb +12 -0
  77. data/lib/tools/web_get.rb +8 -9
  78. data/lib/tools/write.rb +86 -0
  79. data/lib/tui/app.rb +985 -26
  80. data/lib/tui/cable_client.rb +69 -31
  81. data/lib/tui/message_store.rb +103 -8
  82. data/lib/tui/screens/chat.rb +293 -45
  83. data/lib/workflows/definition.rb +97 -0
  84. data/lib/workflows/registry.rb +89 -0
  85. data/skills/activerecord/SKILL.md +255 -0
  86. data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
  87. data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
  88. data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
  89. data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
  90. data/skills/activerecord/examples/associations/self_referential.rb +302 -0
  91. data/skills/activerecord/examples/associations/through_associations.rb +203 -0
  92. data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
  93. data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
  94. data/skills/activerecord/examples/basics/inheritance.rb +377 -0
  95. data/skills/activerecord/examples/basics/type_casting.rb +317 -0
  96. data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
  97. data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
  98. data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
  99. data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
  100. data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
  101. data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
  102. data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
  103. data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
  104. data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
  105. data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
  106. data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
  107. data/skills/activerecord/examples/querying/optimization.rb +275 -0
  108. data/skills/activerecord/examples/querying/scopes.rb +260 -0
  109. data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
  110. data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
  111. data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
  112. data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
  113. data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
  114. data/skills/activerecord/references/associations.md +709 -0
  115. data/skills/activerecord/references/basics.md +622 -0
  116. data/skills/activerecord/references/callbacks.md +738 -0
  117. data/skills/activerecord/references/migrations.md +657 -0
  118. data/skills/activerecord/references/querying.md +655 -0
  119. data/skills/activerecord/references/validations.md +596 -0
  120. data/skills/dragonruby/SKILL.md +250 -0
  121. data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
  122. data/skills/dragonruby/examples/audio/background_music.rb +29 -0
  123. data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
  124. data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
  125. data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
  126. data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
  127. data/skills/dragonruby/examples/core/hello_world.rb +24 -0
  128. data/skills/dragonruby/examples/core/labels.rb +22 -0
  129. data/skills/dragonruby/examples/core/sprites.rb +35 -0
  130. data/skills/dragonruby/examples/core/state_management.rb +29 -0
  131. data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
  132. data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
  133. data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
  134. data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
  135. data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
  136. data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
  137. data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
  138. data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
  139. data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
  140. data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
  141. data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
  142. data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
  143. data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
  144. data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
  145. data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
  146. data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
  147. data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
  148. data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
  149. data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
  150. data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
  151. data/skills/dragonruby/examples/input/controller_input.rb +28 -0
  152. data/skills/dragonruby/examples/input/directional_input.rb +24 -0
  153. data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
  154. data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
  155. data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
  156. data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
  157. data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
  158. data/skills/dragonruby/examples/rendering/labels.rb +32 -0
  159. data/skills/dragonruby/examples/rendering/layering.rb +51 -0
  160. data/skills/dragonruby/examples/rendering/solids.rb +61 -0
  161. data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
  162. data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
  163. data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
  164. data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
  165. data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
  166. data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
  167. data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
  168. data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
  169. data/skills/dragonruby/references/audio.md +396 -0
  170. data/skills/dragonruby/references/core.md +385 -0
  171. data/skills/dragonruby/references/distribution.md +434 -0
  172. data/skills/dragonruby/references/entities.md +516 -0
  173. data/skills/dragonruby/references/game-logic/persistence.md +386 -0
  174. data/skills/dragonruby/references/game-logic/state.md +389 -0
  175. data/skills/dragonruby/references/input.md +414 -0
  176. data/skills/dragonruby/references/rendering/animation.md +467 -0
  177. data/skills/dragonruby/references/rendering/primitives.md +403 -0
  178. data/skills/dragonruby/references/scenes.md +443 -0
  179. data/skills/draper-decorators/SKILL.md +344 -0
  180. data/skills/draper-decorators/examples/application_decorator.rb +61 -0
  181. data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
  182. data/skills/draper-decorators/examples/model_decorator.rb +152 -0
  183. data/skills/draper-decorators/references/anti-patterns.md +640 -0
  184. data/skills/draper-decorators/references/patterns.md +507 -0
  185. data/skills/draper-decorators/references/testing.md +559 -0
  186. data/skills/gh-issue.md +182 -0
  187. data/skills/mcp-server/SKILL.md +177 -0
  188. data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
  189. data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
  190. data/skills/mcp-server/examples/http_client.rb +48 -0
  191. data/skills/mcp-server/examples/http_server.rb +97 -0
  192. data/skills/mcp-server/examples/rails_integration.rb +88 -0
  193. data/skills/mcp-server/examples/stdio_server.rb +108 -0
  194. data/skills/mcp-server/examples/streaming_client.rb +95 -0
  195. data/skills/mcp-server/references/gotchas.md +183 -0
  196. data/skills/mcp-server/references/prompts.md +98 -0
  197. data/skills/mcp-server/references/resources.md +53 -0
  198. data/skills/mcp-server/references/server.md +140 -0
  199. data/skills/mcp-server/references/tools.md +146 -0
  200. data/skills/mcp-server/references/transport.md +104 -0
  201. data/skills/ratatui-ruby/SKILL.md +315 -0
  202. data/skills/ratatui-ruby/references/core-concepts.md +340 -0
  203. data/skills/ratatui-ruby/references/events.md +387 -0
  204. data/skills/ratatui-ruby/references/frameworks.md +522 -0
  205. data/skills/ratatui-ruby/references/layout.md +423 -0
  206. data/skills/ratatui-ruby/references/styling.md +268 -0
  207. data/skills/ratatui-ruby/references/testing.md +433 -0
  208. data/skills/ratatui-ruby/references/widgets.md +532 -0
  209. data/skills/rspec/SKILL.md +340 -0
  210. data/skills/rspec/examples/core/basic_structure.rb +69 -0
  211. data/skills/rspec/examples/core/configuration.rb +126 -0
  212. data/skills/rspec/examples/core/hooks.rb +126 -0
  213. data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
  214. data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
  215. data/skills/rspec/examples/core/shared_examples.rb +145 -0
  216. data/skills/rspec/examples/factory_bot/associations.rb +314 -0
  217. data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
  218. data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
  219. data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
  220. data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
  221. data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
  222. data/skills/rspec/examples/factory_bot/traits.rb +293 -0
  223. data/skills/rspec/examples/factory_bot/transients.rb +229 -0
  224. data/skills/rspec/examples/matchers/change.rb +115 -0
  225. data/skills/rspec/examples/matchers/collections.rb +154 -0
  226. data/skills/rspec/examples/matchers/comparisons.rb +79 -0
  227. data/skills/rspec/examples/matchers/composing.rb +155 -0
  228. data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
  229. data/skills/rspec/examples/matchers/equality.rb +58 -0
  230. data/skills/rspec/examples/matchers/errors.rb +136 -0
  231. data/skills/rspec/examples/matchers/output.rb +103 -0
  232. data/skills/rspec/examples/matchers/predicates.rb +87 -0
  233. data/skills/rspec/examples/matchers/truthiness.rb +101 -0
  234. data/skills/rspec/examples/matchers/types.rb +82 -0
  235. data/skills/rspec/examples/matchers/yield.rb +147 -0
  236. data/skills/rspec/examples/mocks/any_instance.rb +172 -0
  237. data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
  238. data/skills/rspec/examples/mocks/constants.rb +177 -0
  239. data/skills/rspec/examples/mocks/doubles.rb +139 -0
  240. data/skills/rspec/examples/mocks/expectations.rb +137 -0
  241. data/skills/rspec/examples/mocks/message_chains.rb +173 -0
  242. data/skills/rspec/examples/mocks/ordering.rb +144 -0
  243. data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
  244. data/skills/rspec/examples/mocks/responses.rb +223 -0
  245. data/skills/rspec/examples/mocks/spies.rb +149 -0
  246. data/skills/rspec/examples/mocks/stubbing.rb +133 -0
  247. data/skills/rspec/examples/rails/channels.rb +250 -0
  248. data/skills/rspec/examples/rails/controller_specs.rb +302 -0
  249. data/skills/rspec/examples/rails/helper_specs.rb +245 -0
  250. data/skills/rspec/examples/rails/job_specs.rb +256 -0
  251. data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
  252. data/skills/rspec/examples/rails/matchers.rb +374 -0
  253. data/skills/rspec/examples/rails/model_specs.rb +193 -0
  254. data/skills/rspec/examples/rails/request_specs.rb +275 -0
  255. data/skills/rspec/examples/rails/routing_specs.rb +276 -0
  256. data/skills/rspec/examples/rails/system_specs.rb +294 -0
  257. data/skills/rspec/examples/rails/transactions.rb +254 -0
  258. data/skills/rspec/examples/rails/view_specs.rb +252 -0
  259. data/skills/rspec/references/core.md +816 -0
  260. data/skills/rspec/references/factory_bot.md +641 -0
  261. data/skills/rspec/references/matchers.md +516 -0
  262. data/skills/rspec/references/mocks.md +381 -0
  263. data/skills/rspec/references/rails.md +528 -0
  264. data/templates/soul.md +40 -0
  265. data/workflows/commit.md +45 -0
  266. data/workflows/create_handoff.md +98 -0
  267. data/workflows/create_note.md +82 -0
  268. data/workflows/create_plan.md +457 -0
  269. data/workflows/decompose_ticket.md +109 -0
  270. data/workflows/feature.md +91 -0
  271. data/workflows/implement_plan.md +87 -0
  272. data/workflows/iterate_plan.md +247 -0
  273. data/workflows/research_codebase.md +210 -0
  274. data/workflows/resume_handoff.md +217 -0
  275. data/workflows/review_pr.md +320 -0
  276. data/workflows/thoughts_init.md +71 -0
  277. data/workflows/validate_plan.md +166 -0
  278. metadata +290 -3
  279. data/app/controllers/api/sessions_controller.rb +0 -25
  280. data/lib/events/subscribers/action_cable_bridge.rb +0 -59
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticalBrain
4
+ module Tools
5
+ # Marks a goal as completed on the main session. Sets the status to
6
+ # "completed" and records the completion timestamp.
7
+ class FinishGoal < ::Tools::Base
8
+ def self.tool_name = "finish_goal"
9
+
10
+ def self.description = "Mark a goal as completed. " \
11
+ "Use this when the main agent has finished the work described by the goal."
12
+
13
+ def self.input_schema
14
+ {
15
+ type: "object",
16
+ properties: {
17
+ goal_id: {
18
+ type: "integer",
19
+ description: "ID of the goal to mark as completed"
20
+ }
21
+ },
22
+ required: %w[goal_id]
23
+ }
24
+ end
25
+
26
+ # @param main_session [Session] the session owning the goal
27
+ def initialize(main_session:, **)
28
+ @main_session = main_session
29
+ end
30
+
31
+ # @param input [Hash<String, Object>] with "goal_id"
32
+ # @return [String] confirmation message
33
+ # @return [Hash] with :error key on failure
34
+ def execute(input)
35
+ goal_id = input["goal_id"]
36
+ goal = @main_session.goals.find_by(id: goal_id)
37
+ return {error: "Goal not found (id: #{goal_id})"} unless goal
38
+
39
+ complete(goal)
40
+ end
41
+
42
+ private
43
+
44
+ # Marks the goal as completed. Root goals cascade completion to all
45
+ # active sub-goals within a single transaction so the after_commit
46
+ # broadcast includes the fully cascaded state.
47
+ #
48
+ # Returns an error for already-completed goals so the analytical
49
+ # brain learns to check status before retrying.
50
+ def complete(goal)
51
+ id = goal.id
52
+ return {error: "Goal already completed: #{goal.description} (id: #{id})"} if goal.completed?
53
+
54
+ Goal.transaction do
55
+ goal.update!(status: "completed", completed_at: Time.current)
56
+ goal.cascade_completion! if goal.root?
57
+ end
58
+ "Goal completed: #{goal.description} (id: #{id})"
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticalBrain
4
+ module Tools
5
+ # Reads and activates a workflow on the main session.
6
+ # Returns the full workflow content so the brain can create goals from it.
7
+ # Also sets the workflow as active on the session, injecting its content
8
+ # into the main agent's "Your Expertise" section.
9
+ class ReadWorkflow < ::Tools::Base
10
+ def self.tool_name = "read_workflow"
11
+
12
+ def self.description = "Read a workflow's full content and activate it on the session. " \
13
+ "Use the content to create appropriate goals with set_goal."
14
+
15
+ def self.input_schema
16
+ {
17
+ type: "object",
18
+ properties: {
19
+ name: {
20
+ type: "string",
21
+ description: "Name of the workflow to read (from the available workflows list)"
22
+ }
23
+ },
24
+ required: %w[name]
25
+ }
26
+ end
27
+
28
+ # @param main_session [Session] the session to activate the workflow on
29
+ def initialize(main_session:, **)
30
+ @main_session = main_session
31
+ end
32
+
33
+ # @param input [Hash<String, Object>] with "name" key
34
+ # @return [String] workflow name, description, and full content
35
+ # @return [Hash] with :error key on validation failure
36
+ def execute(input)
37
+ workflow_name = input["name"].to_s.strip
38
+ return {error: "Workflow name cannot be blank"} if workflow_name.empty?
39
+
40
+ workflow = @main_session.activate_workflow(workflow_name)
41
+ format_workflow(workflow)
42
+ rescue Workflows::InvalidDefinitionError => error
43
+ {error: error.message}
44
+ end
45
+
46
+ private
47
+
48
+ def format_workflow(workflow)
49
+ <<~CONTENT
50
+ Workflow: #{workflow.name}
51
+ Description: #{workflow.description}
52
+
53
+ #{workflow.content}
54
+ CONTENT
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticalBrain
4
+ module Tools
5
+ # Renames the main session with an emoji and short descriptive name.
6
+ # Operates on the main session passed through the registry context,
7
+ # not on the phantom analytical brain session.
8
+ #
9
+ # The analytical brain calls this when a conversation's topic becomes
10
+ # clear or shifts significantly enough to warrant a new name.
11
+ class RenameSession < ::Tools::Base
12
+ def self.tool_name = "rename_session"
13
+
14
+ def self.description = "Rename the conversation session. " \
15
+ "Use one emoji followed by 1-3 descriptive words."
16
+
17
+ def self.input_schema
18
+ {
19
+ type: "object",
20
+ properties: {
21
+ emoji: {
22
+ type: "string",
23
+ description: "A single emoji representing the conversation topic"
24
+ },
25
+ name: {
26
+ type: "string",
27
+ description: "1-3 word descriptive name for the session"
28
+ }
29
+ },
30
+ required: %w[emoji name]
31
+ }
32
+ end
33
+
34
+ # @param main_session [Session] the session to rename
35
+ def initialize(main_session:, **)
36
+ @main_session = main_session
37
+ end
38
+
39
+ # @param input [Hash<String, Object>] with "emoji" and "name" keys
40
+ # @return [String] confirmation message
41
+ # @return [Hash] with :error key on validation failure
42
+ def execute(input)
43
+ error = validate(input)
44
+ return error if error
45
+
46
+ full_name = build_name(input)
47
+ @main_session.update!(name: full_name)
48
+ "Session renamed to: #{full_name}"
49
+ end
50
+
51
+ private
52
+
53
+ def validate(input)
54
+ return {error: "Emoji cannot be blank"} if input["emoji"].to_s.strip.empty?
55
+ {error: "Name cannot be blank"} if input["name"].to_s.strip.empty?
56
+ end
57
+
58
+ def build_name(input)
59
+ "#{input["emoji"].to_s.strip} #{input["name"].to_s.strip}".truncate(255)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticalBrain
4
+ module Tools
5
+ # Creates a goal on the main session. Root goals represent high-level
6
+ # objectives (semantic episodes); sub-goals are TODO-style steps within
7
+ # a root goal. The two-level hierarchy is enforced by the Goal model.
8
+ class SetGoal < ::Tools::Base
9
+ def self.tool_name = "set_goal"
10
+
11
+ def self.description = "Create a goal on the main session. " \
12
+ "Omit parent_goal_id for a root goal, or provide it to create a sub-goal (TODO item)."
13
+
14
+ def self.input_schema
15
+ {
16
+ type: "object",
17
+ properties: {
18
+ description: {
19
+ type: "string",
20
+ description: "What needs to be accomplished (1-2 sentences)"
21
+ },
22
+ parent_goal_id: {
23
+ type: "integer",
24
+ description: "ID of the parent goal (omit for root goals)"
25
+ }
26
+ },
27
+ required: %w[description]
28
+ }
29
+ end
30
+
31
+ # @param main_session [Session] the session to create the goal on
32
+ def initialize(main_session:, **)
33
+ @main_session = main_session
34
+ end
35
+
36
+ # @param input [Hash<String, Object>] with "description" and optional "parent_goal_id"
37
+ # @return [String] confirmation with goal ID
38
+ # @return [Hash] with :error key on validation failure
39
+ def execute(input)
40
+ description = input["description"].to_s.strip
41
+ return {error: "Description cannot be blank"} if description.empty?
42
+
43
+ goal = @main_session.goals.create!(
44
+ description: description,
45
+ parent_goal_id: input["parent_goal_id"]
46
+ )
47
+ format_confirmation(goal)
48
+ rescue ActiveRecord::RecordInvalid => error
49
+ {error: error.record.errors.full_messages.join(", ")}
50
+ end
51
+
52
+ private
53
+
54
+ def format_confirmation(goal)
55
+ prefix = goal.parent_goal_id ? "Sub-goal" : "Goal"
56
+ "#{prefix} created: #{goal.description} (id: #{goal.id})"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticalBrain
4
+ module Tools
5
+ # Updates a goal's description on the main session.
6
+ #
7
+ # The analytical brain creates goals early when intent is vague, then
8
+ # refines them as the conversation clarifies scope — e.g. "implement auth"
9
+ # becomes "implement OAuth2 middleware for API endpoints". Without this
10
+ # tool the brain would have to choose between keeping a stale description
11
+ # or creating a duplicate goal.
12
+ #
13
+ # Completed goals cannot be updated; attempting to do so returns an error
14
+ # so the brain learns to check status before calling this tool.
15
+ class UpdateGoal < ::Tools::Base
16
+ def self.tool_name = "update_goal"
17
+
18
+ def self.description = "Update a goal's description. " \
19
+ "Use this to refine a goal as understanding evolves."
20
+
21
+ def self.input_schema
22
+ {
23
+ type: "object",
24
+ properties: {
25
+ goal_id: {
26
+ type: "integer",
27
+ description: "ID of the goal to update"
28
+ },
29
+ description: {
30
+ type: "string",
31
+ description: "New description for the goal (1-2 sentences)"
32
+ }
33
+ },
34
+ required: %w[goal_id description]
35
+ }
36
+ end
37
+
38
+ # @param main_session [Session] the session owning the goal
39
+ def initialize(main_session:, **)
40
+ @main_session = main_session
41
+ end
42
+
43
+ # @param input [Hash<String, Object>] with "goal_id" and "description"
44
+ # @return [String] confirmation message
45
+ # @return [Hash] with :error key on failure
46
+ def execute(input)
47
+ goal_id = input["goal_id"]
48
+ description = input["description"].to_s.strip
49
+ return {error: "Description cannot be blank"} if description.empty?
50
+
51
+ goal = @main_session.goals.find_by(id: goal_id)
52
+ return {error: "Goal not found (id: #{goal_id})"} unless goal
53
+ return {error: "Cannot update completed goal: #{goal.description} (id: #{goal_id})"} if goal.completed?
54
+
55
+ goal.update!(description: description)
56
+ "Goal updated: #{description} (id: #{goal_id})"
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticalBrain
4
+ # Dev-only logger that writes to log/analytical_brain.log.
5
+ # In non-development environments returns a null logger so
6
+ # call sites don't need conditionals.
7
+ #
8
+ # @return [Logger]
9
+ def self.logger
10
+ @logger ||= build_logger
11
+ end
12
+
13
+ def self.build_logger
14
+ return Logger.new(File::NULL) unless Rails.env.development?
15
+
16
+ Logger.new(Rails.root.join("log", "analytical_brain.log")).tap do |log|
17
+ log.formatter = proc { |severity, time, _progname, msg|
18
+ "[#{time.strftime("%H:%M:%S.%L")}] #{severity} #{msg}\n"
19
+ }
20
+ end
21
+ end
22
+ private_class_method :build_logger
23
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Anima
6
+ class CLI < Thor
7
+ class Mcp < Thor
8
+ # CLI commands for managing MCP secrets stored in Rails encrypted
9
+ # credentials. Secrets are referenced in mcp.toml via
10
+ # +${credential:key_name}+ syntax.
11
+ #
12
+ # @example Store a secret
13
+ # anima mcp secrets set linear_api_key=sk-xxx
14
+ #
15
+ # @example List stored secret names
16
+ # anima mcp secrets list
17
+ #
18
+ # @example Remove a secret
19
+ # anima mcp secrets remove linear_api_key
20
+ class Secrets < Thor
21
+ def self.exit_on_failure?
22
+ true
23
+ end
24
+
25
+ desc "set KEY=VALUE", "Store an MCP secret in encrypted credentials"
26
+ def set(pair)
27
+ key, value = pair.split("=", 2)
28
+ unless value
29
+ say "Error: expected KEY=VALUE format, got '#{pair}'", :red
30
+ exit 1
31
+ end
32
+
33
+ require_mcp_secrets.set(key, value)
34
+ say "Stored secret '#{key}'.", :green
35
+ rescue ArgumentError => argument_error
36
+ say "Error: #{argument_error.message}", :red
37
+ exit 1
38
+ end
39
+
40
+ desc "list", "List stored MCP secret names (not values)"
41
+ def list
42
+ keys = require_mcp_secrets.list
43
+
44
+ if keys.empty?
45
+ say "No MCP secrets stored.", :yellow
46
+ say "Add one with: anima mcp secrets set KEY=VALUE"
47
+ return
48
+ end
49
+
50
+ keys.each { |key| say " #{key}" }
51
+ end
52
+
53
+ desc "remove KEY", "Remove an MCP secret from encrypted credentials"
54
+ def remove(key)
55
+ secrets = require_mcp_secrets
56
+ unless secrets.list.include?(key)
57
+ say "Error: secret '#{key}' not found", :red
58
+ exit 1
59
+ end
60
+
61
+ secrets.remove(key)
62
+ say "Removed secret '#{key}'.", :green
63
+ end
64
+
65
+ private
66
+
67
+ def require_mcp_secrets
68
+ Anima.boot_rails!
69
+ require_relative "../../../mcp/secrets"
70
+ require_relative "../../../credential_store"
71
+ ::Mcp::Secrets
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require_relative "mcp/secrets"
5
+
6
+ module Anima
7
+ class CLI < Thor
8
+ # CLI commands for managing MCP server configuration in
9
+ # +~/.anima/mcp.toml+. Mirrors the UX of +claude mcp+ commands.
10
+ class Mcp < Thor
11
+ def self.exit_on_failure?
12
+ true
13
+ end
14
+
15
+ desc "secrets SUBCOMMAND", "Manage MCP secrets in encrypted credentials"
16
+ subcommand "secrets", Secrets
17
+
18
+ desc "list", "List configured MCP servers with health status"
19
+ def list
20
+ config = build_config
21
+ raw_servers = config.all_servers
22
+
23
+ if raw_servers.empty?
24
+ say "No MCP servers configured.", :yellow
25
+ say "Add one with: anima mcp add <name> <url>"
26
+ return
27
+ end
28
+
29
+ interpolated = interpolated_lookup(config)
30
+ raw_servers.each { |server| display_server(server, interpolated[server["name"]]) }
31
+ config.warnings.each { |warning| say " warning: #{warning}", :yellow }
32
+ end
33
+
34
+ desc "add NAME URL_OR_COMMAND", "Add an MCP server"
35
+ long_desc <<~DESC
36
+ Add an MCP server to ~/.anima/mcp.toml.
37
+
38
+ HTTP server: anima mcp add <name> <url>
39
+ Stdio server: anima mcp add <name> -- <command> [args...]
40
+
41
+ Use -e KEY=VALUE to set environment variables (stdio servers).
42
+ Use -H "Header: Value" to set HTTP headers (HTTP servers).
43
+ Use -s KEY=VALUE to store a secret in encrypted credentials.
44
+ DESC
45
+ option :env, aliases: "-e", type: :string, repeatable: true, banner: "KEY=VALUE",
46
+ desc: "Environment variables (repeatable)"
47
+ option :header, aliases: "-H", type: :string, repeatable: true, banner: "Header: Value",
48
+ desc: "HTTP headers (repeatable)"
49
+ option :secret, aliases: "-s", type: :string, repeatable: true, banner: "KEY=VALUE",
50
+ desc: "Store secret in encrypted credentials (repeatable)"
51
+ def add(name, *rest)
52
+ if rest.empty?
53
+ say "Error: missing server URL or command.", :red
54
+ say ""
55
+ say "Usage:"
56
+ say " anima mcp add <name> <url> # HTTP server"
57
+ say " anima mcp add <name> -- <command> [args...] # stdio server"
58
+ abort_command
59
+ end
60
+
61
+ store_secrets(options[:secret])
62
+ settings = build_settings(rest)
63
+ build_config.add_server(name, settings)
64
+ say "Added #{settings["transport"]} server '#{name}' (#{settings_target(settings)}).", :green
65
+ rescue ArgumentError => argument_error
66
+ say "Error: #{argument_error.message}", :red
67
+ abort_command
68
+ end
69
+
70
+ desc "remove NAME", "Remove an MCP server"
71
+ def remove(name)
72
+ build_config.remove_server(name)
73
+ say "Removed server '#{name}'.", :green
74
+ rescue ArgumentError => argument_error
75
+ say "Error: #{argument_error.message}", :red
76
+ abort_command
77
+ end
78
+
79
+ private
80
+
81
+ def abort_command
82
+ exit 1
83
+ end
84
+
85
+ def build_config
86
+ require_relative "../../mcp/config"
87
+ ::Mcp::Config.new
88
+ end
89
+
90
+ # Stores secrets from -s KEY=VALUE flags in encrypted credentials.
91
+ def store_secrets(secret_strings)
92
+ return unless secret_strings&.any?
93
+
94
+ pairs = parse_key_values(secret_strings, label: "secret")
95
+ Anima.boot_rails!
96
+ require_relative "../../mcp/secrets"
97
+ require_relative "../../credential_store"
98
+ pairs.each { |key, value| ::Mcp::Secrets.set(key, value) }
99
+ end
100
+
101
+ # Builds interpolated server lookup keyed by name for health checks.
102
+ def interpolated_lookup(config)
103
+ lookup = {}
104
+ populate_lookup(lookup, config.http_servers, "http")
105
+ populate_lookup(lookup, config.stdio_servers, "stdio")
106
+ lookup
107
+ end
108
+
109
+ def populate_lookup(lookup, servers, transport)
110
+ servers.each { |server| lookup[server[:name]] = server.merge(transport: transport) }
111
+ end
112
+
113
+ # Detects transport from arguments:
114
+ # - First arg starts with http(s):// → HTTP server
115
+ # - Otherwise → stdio server (command + args)
116
+ def build_settings(args)
117
+ first_arg = args.first
118
+ if first_arg.match?(%r{\Ahttps?://})
119
+ build_http_settings(first_arg)
120
+ else
121
+ build_stdio_settings(args)
122
+ end
123
+ end
124
+
125
+ def build_http_settings(url)
126
+ settings = {"transport" => "http", "url" => url}
127
+ headers = options[:header]
128
+ settings["headers"] = parse_headers(headers) if headers&.any?
129
+ settings
130
+ end
131
+
132
+ def build_stdio_settings(args)
133
+ command, *remaining_args = args
134
+ settings = {"transport" => "stdio", "command" => command}
135
+ settings["args"] = remaining_args if remaining_args.any?
136
+ env_vars = options[:env]
137
+ settings["env"] = parse_key_values(env_vars) if env_vars&.any?
138
+ settings
139
+ end
140
+
141
+ def settings_target(settings)
142
+ if settings["transport"] == "http"
143
+ settings["url"]
144
+ else
145
+ [settings["command"], *settings["args"]].join(" ")
146
+ end
147
+ end
148
+
149
+ def parse_headers(header_strings)
150
+ header_strings.to_h do |header|
151
+ key, value = header.split(": ", 2)
152
+ raise ArgumentError, "invalid header format '#{header}' — expected 'Name: Value'" unless value
153
+
154
+ [key, value]
155
+ end
156
+ end
157
+
158
+ def parse_key_values(kv_strings, label: "env var")
159
+ kv_strings.to_h do |kv|
160
+ key, value = kv.split("=", 2)
161
+ raise ArgumentError, "invalid #{label} format '#{kv}' — expected KEY=VALUE" unless value
162
+
163
+ [key, value]
164
+ end
165
+ end
166
+
167
+ def display_server(raw, interpolated)
168
+ name = raw["name"]
169
+ transport = raw["transport"]
170
+ detail = server_detail(raw, transport)
171
+ status = interpolated ? check_health(interpolated) : set_color("config error", :red)
172
+
173
+ say " #{name}: #{detail} (#{transport}) — #{status}"
174
+ end
175
+
176
+ def server_detail(raw, transport)
177
+ case transport
178
+ when "http" then raw["url"]
179
+ when "stdio" then [raw["command"], *raw["args"]].compact.join(" ")
180
+ else "unknown transport '#{transport}'"
181
+ end
182
+ end
183
+
184
+ def check_health(server)
185
+ require_relative "../../mcp/health_check"
186
+ result = ::Mcp::HealthCheck.call(server)
187
+
188
+ case result[:status]
189
+ when :connected
190
+ set_color("connected (#{result[:tools]} tools)", :green)
191
+ when :failed
192
+ set_color("failed: #{result[:error]}", :red)
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
data/lib/anima/cli.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "thor"
4
4
  require_relative "../anima"
5
+ require_relative "cli/mcp"
5
6
 
6
7
  module Anima
7
8
  class CLI < Thor
@@ -47,17 +48,13 @@ module Anima
47
48
  option :host, desc: "Brain server address (default: #{DEFAULT_HOST})"
48
49
  def tui
49
50
  require "ratatui_ruby"
50
- require "net/http"
51
- require "json"
52
51
  require_relative "../tui/app"
53
52
 
54
53
  host = options[:host] || DEFAULT_HOST
55
54
 
56
55
  say "Connecting to brain at #{host}...", :cyan
57
- session_id = fetch_current_session_with_retry(host)
58
- say "Session ##{session_id} — starting TUI", :cyan
59
56
 
60
- cable_client = TUI::CableClient.new(host: host, session_id: session_id)
57
+ cable_client = TUI::CableClient.new(host: host)
61
58
  cable_client.connect
62
59
 
63
60
  TUI::App.new(cable_client: cable_client).run
@@ -70,41 +67,9 @@ module Anima
70
67
  say "anima #{Anima::VERSION}"
71
68
  end
72
69
 
73
- private
74
-
75
- MAX_SESSION_FETCH_ATTEMPTS = 10
76
- SESSION_FETCH_DELAY = 2 # seconds between retries
77
-
78
- # Fetches the current session ID from the brain's REST API.
79
- # Retries up to {MAX_SESSION_FETCH_ATTEMPTS} times if the brain is not running.
80
- #
81
- # @param host [String] brain server address
82
- # @return [Integer] session ID
83
- def fetch_current_session_with_retry(host)
84
- attempts = 0
85
- begin
86
- fetch_current_session(host)
87
- rescue Errno::ECONNREFUSED, Net::ReadTimeout, Net::OpenTimeout, SocketError => error
88
- attempts += 1
89
- if attempts >= MAX_SESSION_FETCH_ATTEMPTS
90
- say "Cannot connect to brain after #{MAX_SESSION_FETCH_ATTEMPTS} attempts", :red
91
- exit 1
92
- end
93
- say "Brain not available (#{error.class.name.split("::").last}). " \
94
- "Retrying #{attempts}/#{MAX_SESSION_FETCH_ATTEMPTS}... (Ctrl+C to cancel)", :yellow
95
- sleep SESSION_FETCH_DELAY
96
- retry
97
- end
98
- end
70
+ desc "mcp SUBCOMMAND", "Manage MCP server configuration"
71
+ subcommand "mcp", Mcp
99
72
 
100
- # Fetches the current session ID from the brain's REST API.
101
- # @param host [String] brain server address
102
- # @return [Integer] session ID
103
- # @raise [RuntimeError] if the brain returns an error response
104
- def fetch_current_session(host)
105
- uri = URI("http://#{host}/api/sessions/current")
106
- body = Net::HTTP.get(uri)
107
- JSON.parse(body)["id"]
108
- end
73
+ private
109
74
  end
110
75
  end