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,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Agents
6
+ class InvalidDefinitionError < StandardError; end
7
+
8
+ # A named sub-agent parsed from a Markdown definition file.
9
+ # YAML frontmatter holds metadata; the Markdown body is the system prompt.
10
+ #
11
+ # @example Definition file format
12
+ # ---
13
+ # name: codebase-analyzer
14
+ # description: Analyzes codebase implementation details.
15
+ # tools: read, bash
16
+ # model: claude-sonnet-4-5
17
+ # ---
18
+ #
19
+ # You are a specialist at understanding HOW code works...
20
+ class Definition
21
+ # @return [String] unique agent identifier used in spawn_specialist(name: "...")
22
+ attr_reader :name
23
+
24
+ # @return [String] description shown to the LLM in the tool catalog
25
+ attr_reader :description
26
+
27
+ # @return [Array<String>] tool names available to this agent
28
+ attr_reader :tools
29
+
30
+ # @return [String] system prompt (Markdown body of the definition file)
31
+ attr_reader :prompt
32
+
33
+ # @return [String, nil] LLM model override (reserved for future use)
34
+ attr_reader :model
35
+
36
+ # @return [String, nil] TUI display color (reserved for future use)
37
+ attr_reader :color
38
+
39
+ # @return [Integer, nil] maximum conversation turns (reserved for future use)
40
+ attr_reader :max_turns
41
+
42
+ # @return [String] file path this definition was loaded from
43
+ attr_reader :source_path
44
+
45
+ def initialize(name:, description:, tools:, prompt:, model: nil, color: nil, max_turns: nil, source_path: "")
46
+ @name = name
47
+ @description = description
48
+ @tools = tools
49
+ @prompt = prompt
50
+ @model = model
51
+ @color = color
52
+ @max_turns = max_turns
53
+ @source_path = source_path
54
+ end
55
+
56
+ # Parses a Markdown file with YAML frontmatter into a Definition.
57
+ #
58
+ # @param path [String, Pathname] path to the .md file
59
+ # @return [Definition]
60
+ # @raise [InvalidDefinitionError] if required fields are missing or frontmatter is malformed
61
+ def self.from_file(path)
62
+ content = File.read(path)
63
+ frontmatter, body = parse_frontmatter(content)
64
+
65
+ validate_required_fields!(frontmatter, path)
66
+
67
+ new(
68
+ name: frontmatter["name"].to_s.strip,
69
+ description: frontmatter["description"].to_s.strip,
70
+ tools: parse_tools(frontmatter["tools"]),
71
+ prompt: body.strip,
72
+ model: frontmatter["model"]&.to_s&.strip,
73
+ color: frontmatter["color"]&.to_s&.strip,
74
+ max_turns: frontmatter["maxTurns"]&.to_i,
75
+ source_path: path.to_s
76
+ )
77
+ end
78
+
79
+ # @param content [String] raw file content with YAML frontmatter
80
+ # @return [Array(Hash, String)] parsed frontmatter and body text
81
+ # @raise [InvalidDefinitionError] if frontmatter is missing or malformed
82
+ def self.parse_frontmatter(content)
83
+ # Opening "---" must be followed by a newline (not just whitespace).
84
+ # Non-greedy (.*?\n) captures YAML lines up to the closing "---".
85
+ # Closing "---" may optionally be followed by a newline before the body.
86
+ # The /m flag lets (.*) in the body capture across newlines.
87
+ match = content.match(/\A---\s*\n(.*?\n)---\s*\n?(.*)\z/m)
88
+ raise InvalidDefinitionError, "Missing YAML frontmatter" unless match
89
+
90
+ frontmatter = YAML.safe_load(match[1])
91
+ raise InvalidDefinitionError, "Frontmatter is not a valid YAML mapping" unless frontmatter.is_a?(Hash)
92
+
93
+ [frontmatter, match[2]]
94
+ end
95
+
96
+ # Accepts comma-separated string or array of tool names.
97
+ #
98
+ # @param tools_value [String, Array, nil] raw tools field from frontmatter
99
+ # @return [Array<String>] normalized lowercase tool names
100
+ def self.parse_tools(tools_value)
101
+ return [] if tools_value.nil?
102
+
103
+ names = tools_value.is_a?(Array) ? tools_value : tools_value.to_s.split(",")
104
+ names.map { |tool| tool.to_s.strip.downcase }.reject(&:empty?).uniq
105
+ end
106
+
107
+ def self.validate_required_fields!(frontmatter, path)
108
+ %w[name description].each do |field|
109
+ value = frontmatter[field].to_s.strip
110
+ raise InvalidDefinitionError, "Missing required field '#{field}' in #{path}" if value.empty?
111
+ end
112
+ end
113
+
114
+ private_class_method :parse_frontmatter, :parse_tools, :validate_required_fields!
115
+ end
116
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Agents
4
+ # Loads named agent definitions from Markdown files and provides lookup.
5
+ # Scans two directories:
6
+ # 1. Built-in agents shipped with Anima (agents/ in the gem root)
7
+ # 2. User-defined agents (~/.anima/agents/)
8
+ # User agents override built-in ones when names collide.
9
+ class Registry
10
+ # @return [Hash{String => Definition}] loaded definitions keyed by name
11
+ attr_reader :agents
12
+
13
+ BUILTIN_DIR = File.expand_path("../../agents", __dir__).freeze
14
+ USER_DIR = File.expand_path("~/.anima/agents").freeze
15
+
16
+ def initialize
17
+ @agents = {}
18
+ end
19
+
20
+ # Returns the global registry, lazily loaded on first access.
21
+ #
22
+ # @return [Registry]
23
+ def self.instance
24
+ @instance ||= new.load_all
25
+ end
26
+
27
+ # Reloads the global registry from disk.
28
+ #
29
+ # @return [Registry]
30
+ def self.reload!
31
+ @instance = new.load_all
32
+ end
33
+
34
+ # Loads definitions from both built-in and user directories.
35
+ # User definitions override built-in ones with the same name.
36
+ #
37
+ # @return [self]
38
+ def load_all
39
+ load_directory(BUILTIN_DIR)
40
+ load_directory(USER_DIR)
41
+ self
42
+ end
43
+
44
+ # Loads agent definitions from a single directory.
45
+ #
46
+ # @param dir [String] directory path to scan for .md files
47
+ # @return [void]
48
+ def load_directory(dir)
49
+ return unless Dir.exist?(dir)
50
+
51
+ Dir.glob(File.join(dir, "*.md")).sort.each do |path|
52
+ definition = Definition.from_file(path)
53
+ validate_tools!(definition.name, definition.tools)
54
+ @agents[definition.name] = definition
55
+ rescue InvalidDefinitionError => error
56
+ warn "Skipping invalid agent definition #{path}: #{error.message}"
57
+ end
58
+ end
59
+
60
+ # Looks up a named agent definition.
61
+ #
62
+ # @param name [String] agent name
63
+ # @return [Definition, nil]
64
+ def get(name)
65
+ @agents[name]
66
+ end
67
+
68
+ # Agent names and descriptions for inclusion in tool documentation.
69
+ #
70
+ # @return [Hash{String => String}] name => description
71
+ def catalog
72
+ @agents.transform_values(&:description)
73
+ end
74
+
75
+ # @return [Array<String>] registered agent names
76
+ def names
77
+ @agents.keys
78
+ end
79
+
80
+ # @return [Boolean]
81
+ def any?
82
+ @agents.any?
83
+ end
84
+
85
+ # @return [Integer]
86
+ def size
87
+ @agents.size
88
+ end
89
+
90
+ private
91
+
92
+ # Validates that all declared tool names are recognized standard tools.
93
+ #
94
+ # @param agent_name [String] agent name for error messages
95
+ # @param tools [Array<String>] declared tool names
96
+ # @raise [InvalidDefinitionError] if any tool name is unknown
97
+ def validate_tools!(agent_name, tools)
98
+ return if tools.empty?
99
+
100
+ unknown = tools - AgentLoop::STANDARD_TOOLS_BY_NAME.keys
101
+ return if unknown.empty?
102
+
103
+ raise InvalidDefinitionError, "Unknown tools in '#{agent_name}': #{unknown.join(", ")}"
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticalBrain
4
+ # Orchestrates the analytical brain — a phantom (non-persisted) LLM loop
5
+ # that observes a main session and performs background maintenance via tools.
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.
11
+ #
12
+ # @example
13
+ # AnalyticalBrain::Runner.new(session).call
14
+ 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.
33
+
34
+ ──────────────────────────────
35
+ SESSION NAMING
36
+ ──────────────────────────────
37
+ Call rename_session when the topic becomes clear or shifts.
38
+ Format: one emoji + 1-3 descriptive words.
39
+
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.
46
+
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.
55
+
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.
65
+
66
+ ──────────────────────────────
67
+ COMPLETION
68
+ ──────────────────────────────
69
+ Call everything_is_ready as your LAST tool call, every time.
70
+ If nothing needs changing, call it immediately as your only tool call.
71
+ PROMPT
72
+
73
+ # @param session [Session] the main session to observe and maintain
74
+ # @param client [LLM::Client, nil] injectable LLM client (defaults to fast model)
75
+ def initialize(session, client: nil)
76
+ @session = session
77
+ @client = client || LLM::Client.new(
78
+ model: Anima::Settings.fast_model,
79
+ max_tokens: Anima::Settings.analytical_brain_max_tokens,
80
+ logger: AnalyticalBrain.logger
81
+ )
82
+ end
83
+
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.
87
+ #
88
+ # Events emitted during tool execution are not persisted — the phantom
89
+ # session_id (nil) causes the global Persister to skip them.
90
+ #
91
+ # @return [String, nil] the LLM's final text response (discarded by caller),
92
+ # or nil if no context is available
93
+ def call
94
+ messages = build_messages
95
+ sid = @session.id
96
+ if messages.empty?
97
+ log.debug("session=#{sid} — no events, skipping")
98
+ return
99
+ end
100
+
101
+ system = build_system_prompt
102
+ log.info("session=#{sid} — running (#{recent_events.size} events)")
103
+ log.debug("system prompt:\n#{system}")
104
+ log.debug("user message:\n#{messages.first[:content]}")
105
+
106
+ result = @client.chat_with_tools(
107
+ messages,
108
+ registry: build_registry,
109
+ session_id: nil,
110
+ system: system
111
+ )
112
+
113
+ log.info("session=#{sid} — done: #{result.to_s.truncate(200)}")
114
+ result
115
+ end
116
+
117
+ private
118
+
119
+ # 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?"
122
+ #
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.
126
+ #
127
+ # @return [Array<Hash>] single-element messages array, or empty if no events
128
+ def build_messages
129
+ events = recent_events
130
+ return [] if events.empty?
131
+
132
+ transcript = events.filter_map { |event| format_event(event) }.join("\n")
133
+ content = <<~MSG.strip
134
+ The main session is working on this:
135
+ ```
136
+ #{transcript}
137
+ ```
138
+
139
+ Observe the conversation and take action: manage goals, activate or deactivate relevant skills, read workflows when a multi-step task matches, rename the session if needed, then call everything_is_ready.
140
+ MSG
141
+ [{role: "user", content: content}]
142
+ end
143
+
144
+ # @return [Array<Event>] most recent events in chronological order
145
+ def recent_events
146
+ @session.events
147
+ .context_events
148
+ .reorder(id: :desc)
149
+ .limit(Anima::Settings.analytical_brain_event_window)
150
+ .to_a
151
+ .reverse
152
+ end
153
+
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.
174
+ #
175
+ # @return [String]
176
+ def build_system_prompt
177
+ sections = [
178
+ SYSTEM_PROMPT,
179
+ session_state_section,
180
+ skills_catalog_section,
181
+ workflows_catalog_section,
182
+ active_goals_section
183
+ ]
184
+ sections.compact.join("\n")
185
+ end
186
+
187
+ # @return [String] current session name, active skills, and active workflow
188
+ def session_state_section
189
+ name = @session.name || "(unnamed)"
190
+ skills = @session.active_skills.join(", ").presence || "None"
191
+ workflow = @session.active_workflow || "None"
192
+ <<~SECTION
193
+ ──────────────────────────────
194
+ CURRENT STATE
195
+ ──────────────────────────────
196
+ Session name: #{name}
197
+ Active skills: #{skills}
198
+ Active workflow: #{workflow}
199
+ SECTION
200
+ end
201
+
202
+ # @return [String] available skills list for the analytical brain
203
+ def skills_catalog_section
204
+ catalog = Skills::Registry.instance.catalog
205
+ items = if catalog.empty?
206
+ "None"
207
+ else
208
+ catalog.map { |name, desc| "- #{name} — #{desc}" }.join("\n")
209
+ end
210
+ <<~SECTION
211
+ ──────────────────────────────
212
+ AVAILABLE SKILLS
213
+ ──────────────────────────────
214
+ #{items}
215
+ SECTION
216
+ end
217
+
218
+ # @return [String] available workflows list for the analytical brain
219
+ def workflows_catalog_section
220
+ catalog = Workflows::Registry.instance.catalog
221
+ items = if catalog.empty?
222
+ "None"
223
+ else
224
+ catalog.map { |name, desc| "- #{name} — #{desc}" }.join("\n")
225
+ end
226
+ <<~SECTION
227
+ ──────────────────────────────
228
+ AVAILABLE WORKFLOWS
229
+ ──────────────────────────────
230
+ #{items}
231
+ SECTION
232
+ end
233
+
234
+ # @return [String, nil] active goals for the brain's own context,
235
+ # so it knows what already exists and avoids duplicating
236
+ def active_goals_section
237
+ root_goals = @session.goals.root.includes(:sub_goals).active.order(:created_at)
238
+ return if root_goals.empty?
239
+
240
+ lines = root_goals.map { |goal| format_goal_for_brain(goal) }
241
+ <<~SECTION
242
+ ──────────────────────────────
243
+ ACTIVE GOALS
244
+ ──────────────────────────────
245
+ #{lines.join("\n")}
246
+ SECTION
247
+ end
248
+
249
+ # Formats a root goal and its sub-goals as a markdown checklist
250
+ # with IDs so the brain can reference them in finish_goal calls.
251
+ #
252
+ # @example
253
+ # "- Implement feature X (id: 42)\n - [x] Read code (id: 43)\n - [ ] Write tests (id: 44)"
254
+ #
255
+ # @param goal [Goal] root goal with preloaded sub_goals
256
+ # @return [String] goal formatted as markdown checklist for brain context
257
+ def format_goal_for_brain(goal)
258
+ parts = ["- #{goal.description} (id: #{goal.id})"]
259
+ goal.sub_goals.sort_by(&:created_at).each do |sub|
260
+ checkbox = (sub.status == "completed") ? "[x]" : "[ ]"
261
+ parts << " - #{checkbox} #{sub.description} (id: #{sub.id})"
262
+ end
263
+ parts.join("\n")
264
+ end
265
+
266
+ # @return [Logger] dev-only analytical brain logger
267
+ def log = AnalyticalBrain.logger
268
+
269
+ # @return [Tools::Registry] registry with analytical brain tools
270
+ def build_registry
271
+ registry = ::Tools::Registry.new(context: {main_session: @session})
272
+ TOOLS.each { |tool| registry.register(tool) }
273
+ registry
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticalBrain
4
+ module Tools
5
+ # Activates a domain knowledge skill on the main session.
6
+ # The skill's content is injected into the main agent's system prompt,
7
+ # making the knowledge available for the current and future responses.
8
+ class ActivateSkill < ::Tools::Base
9
+ def self.tool_name = "activate_skill"
10
+
11
+ def self.description = "Activate a domain knowledge skill on the main session. " \
12
+ "The skill's content will be injected into the agent's system prompt."
13
+
14
+ def self.input_schema
15
+ {
16
+ type: "object",
17
+ properties: {
18
+ name: {
19
+ type: "string",
20
+ description: "Name of the skill to activate (from the available skills list)"
21
+ }
22
+ },
23
+ required: %w[name]
24
+ }
25
+ end
26
+
27
+ # @param main_session [Session] the session to activate the skill on
28
+ def initialize(main_session:, **)
29
+ @main_session = main_session
30
+ end
31
+
32
+ # @param input [Hash<String, Object>] with "name" key
33
+ # @return [String] confirmation message with skill description
34
+ # @return [Hash] with :error key on validation failure
35
+ def execute(input)
36
+ skill_name = input["name"].to_s.strip
37
+ return {error: "Skill name cannot be blank"} if skill_name.empty?
38
+
39
+ skill = @main_session.activate_skill(skill_name)
40
+ format_confirmation(skill)
41
+ rescue Skills::InvalidDefinitionError => error
42
+ {error: error.message}
43
+ end
44
+
45
+ private
46
+
47
+ def format_confirmation(skill)
48
+ "Activated skill: #{skill.name} (#{skill.description})"
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticalBrain
4
+ module Tools
5
+ # Deactivates a domain knowledge skill on the main session.
6
+ # The skill's content is removed from the main agent's system prompt.
7
+ class DeactivateSkill < ::Tools::Base
8
+ def self.tool_name = "deactivate_skill"
9
+
10
+ def self.description = "Deactivate a skill that is no longer relevant. " \
11
+ "The skill's content will be removed from the agent's system prompt."
12
+
13
+ def self.input_schema
14
+ {
15
+ type: "object",
16
+ properties: {
17
+ name: {
18
+ type: "string",
19
+ description: "Name of the skill to deactivate (from the currently active skills list)"
20
+ }
21
+ },
22
+ required: %w[name]
23
+ }
24
+ end
25
+
26
+ # @param main_session [Session] the session to deactivate the skill on
27
+ def initialize(main_session:, **)
28
+ @main_session = main_session
29
+ end
30
+
31
+ # @param input [Hash<String, Object>] with "name" key
32
+ # @return [String] confirmation message
33
+ # @return [Hash] with :error key on validation failure
34
+ def execute(input)
35
+ skill_name = input["name"].to_s.strip
36
+ return {error: "Skill name cannot be blank"} if skill_name.empty?
37
+
38
+ @main_session.deactivate_skill(skill_name)
39
+ "Deactivated skill: #{skill_name}"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticalBrain
4
+ module Tools
5
+ # Deactivates the current workflow on the main session.
6
+ # The workflow's content is removed from the main agent's system prompt.
7
+ class DeactivateWorkflow < ::Tools::Base
8
+ def self.tool_name = "deactivate_workflow"
9
+
10
+ def self.description = "Deactivate the current workflow when it is complete or no longer relevant."
11
+
12
+ def self.input_schema
13
+ {
14
+ type: "object",
15
+ properties: {},
16
+ required: []
17
+ }
18
+ end
19
+
20
+ # @param main_session [Session] the session to deactivate the workflow on
21
+ def initialize(main_session:, **)
22
+ @main_session = main_session
23
+ end
24
+
25
+ # @param input [Hash<String, Object>] (no parameters needed)
26
+ # @return [String] confirmation message
27
+ def execute(_input)
28
+ previous = @main_session.active_workflow
29
+ @main_session.deactivate_workflow
30
+ previous ? "Deactivated workflow: #{previous}" : "No workflow was active"
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AnalyticalBrain
4
+ module Tools
5
+ # Terminal tool that signals the analytical brain has completed its work.
6
+ # Call this when no changes are needed — the current session state is
7
+ # already good.
8
+ #
9
+ # After this tool returns, the LLM responds with text (not another
10
+ # tool call), naturally terminating the chat_with_tools loop.
11
+ class EverythingIsReady < ::Tools::Base
12
+ def self.tool_name = "everything_is_ready"
13
+
14
+ def self.description = "Signal that no changes are needed. " \
15
+ "Call this when the session name and active skills are already appropriate."
16
+
17
+ def self.input_schema
18
+ {type: "object", properties: {}, required: []}
19
+ end
20
+
21
+ # @param _input [Hash] ignored — this tool takes no input
22
+ # @return [String] confirmation message
23
+ def execute(_input)
24
+ "Acknowledged. No changes needed."
25
+ end
26
+ end
27
+ end
28
+ end