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,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "open3"
5
+ require "timeout"
6
+ require "json"
7
+ require "pathname"
8
+ require "uri"
9
+
10
+ # Probes the shell environment and assembles a lightweight metadata block
11
+ # for injection into the system prompt. Gives the agent awareness of its
12
+ # working directory, OS, Git status, and nearby project files — without
13
+ # loading any file content.
14
+ #
15
+ # @example
16
+ # EnvironmentProbe.to_prompt("/home/user/projects/my-app")
17
+ # # => "## Environment\n\nOS: Arch Linux (pacman, yay)\n..."
18
+ class EnvironmentProbe
19
+ # Assembles the environment context block for a given working directory.
20
+ #
21
+ # @param pwd [String, nil] current working directory
22
+ # @return [String, nil] Markdown-formatted environment block, or nil when pwd is unknown
23
+ def self.to_prompt(pwd)
24
+ new(pwd).to_prompt
25
+ end
26
+
27
+ # @param pwd [String, nil] current working directory
28
+ def initialize(pwd)
29
+ @pwd = pwd
30
+ end
31
+
32
+ # @return [String, nil] Markdown-formatted environment block
33
+ def to_prompt
34
+ return unless @pwd
35
+
36
+ sections = [os_section, working_directory_section, project_files_section].compact
37
+ return if sections.empty?
38
+
39
+ "## Environment\n\n#{sections.join("\n\n")}"
40
+ end
41
+
42
+ private
43
+
44
+ # @return [String] OS name with package manager hint
45
+ def os_section
46
+ sysname = Etc.uname[:sysname]
47
+ "OS: #{format_os(sysname)}"
48
+ end
49
+
50
+ # @param sysname [String] kernel name from uname (e.g. "Linux", "Darwin")
51
+ # @return [String] human-readable OS description
52
+ def format_os(sysname)
53
+ case sysname
54
+ when "Linux"
55
+ distro = detect_linux_distro || "Linux"
56
+ pkg = detect_package_manager
57
+ pkg ? "#{distro} (#{pkg})" : distro
58
+ when "Darwin"
59
+ "macOS (Homebrew)"
60
+ else
61
+ sysname
62
+ end
63
+ end
64
+
65
+ # Reads PRETTY_NAME from /etc/os-release.
66
+ #
67
+ # @return [String, nil] distro name, or nil on non-Linux / missing file
68
+ def detect_linux_distro
69
+ return unless File.exist?("/etc/os-release")
70
+
71
+ File.foreach("/etc/os-release") do |line|
72
+ if line.start_with?("PRETTY_NAME=")
73
+ return line.split("=", 2).last.strip.delete('"')
74
+ end
75
+ end
76
+ nil
77
+ end
78
+
79
+ # Returns the primary package manager(s) for the current system.
80
+ # Arch-based systems list both pacman and yay when present;
81
+ # other families return the first match.
82
+ #
83
+ # @return [String, nil] comma-separated package manager names
84
+ def detect_package_manager
85
+ managers = []
86
+ managers << "pacman" if File.exist?("/usr/bin/pacman")
87
+ managers << "yay" if File.exist?("/usr/bin/yay")
88
+ return managers.join(", ") if managers.any?
89
+
90
+ return "apt" if File.exist?("/usr/bin/apt")
91
+ return "dnf" if File.exist?("/usr/bin/dnf")
92
+ return "Homebrew" if File.exist?("/opt/homebrew/bin/brew") || File.exist?("/usr/local/bin/brew")
93
+
94
+ nil
95
+ end
96
+
97
+ # @return [String] CWD line plus optional Git metadata
98
+ def working_directory_section
99
+ lines = ["CWD: #{@pwd}"]
100
+ append_git_lines(lines)
101
+ lines.join("\n")
102
+ end
103
+
104
+ # Appends Git metadata lines (remote, branch, PR) to the output array.
105
+ #
106
+ # @param lines [Array<String>] accumulator for output lines
107
+ # @return [void]
108
+ def append_git_lines(lines)
109
+ git = detect_git
110
+ return unless git
111
+
112
+ remote = git[:remote]
113
+ branch = git[:branch]
114
+ pr_number = git[:pr_number]
115
+
116
+ lines << "Git: #{git[:repo_name]} (#{remote})" if remote
117
+ lines << "Branch: #{branch}" if branch
118
+ lines << "PR: ##{pr_number} (#{git[:pr_state]})" if pr_number
119
+ end
120
+
121
+ # Detects Git repo metadata: remote, branch, and open PR.
122
+ #
123
+ # @return [Hash{Symbol => String}, nil] keys: :remote, :repo_name, :branch,
124
+ # and optionally :pr_number (Integer) and :pr_state (String); nil when not in a repo
125
+ def detect_git
126
+ _, status = Open3.capture2("git", "-C", @pwd, "rev-parse", "--is-inside-work-tree")
127
+ return unless status.success?
128
+
129
+ info = {}
130
+ detect_git_remote(info)
131
+ detect_git_branch(info)
132
+ info
133
+ rescue Errno::ENOENT
134
+ nil
135
+ end
136
+
137
+ # Populates :remote and :repo_name on the info hash.
138
+ def detect_git_remote(info)
139
+ remote, = Open3.capture2("git", "-C", @pwd, "remote", "get-url", "origin")
140
+ remote = remote.strip
141
+ return unless remote.present?
142
+
143
+ info[:remote] = remote
144
+ info[:repo_name] = extract_repo_name(remote)
145
+ end
146
+
147
+ # Populates :branch, :pr_number, and :pr_state on the info hash.
148
+ def detect_git_branch(info)
149
+ branch, = Open3.capture2("git", "-C", @pwd, "rev-parse", "--abbrev-ref", "HEAD")
150
+ branch = branch.strip
151
+ return unless branch.present?
152
+
153
+ info[:branch] = branch
154
+ pr = detect_pr(branch)
155
+ info.merge!(pr) if pr
156
+ end
157
+
158
+ # Extracts owner/repo from a Git remote URL.
159
+ #
160
+ # @param remote_url [String] SSH or HTTPS remote URL
161
+ # @return [String] "owner/repo" path, or the raw URL when parsing fails
162
+ def extract_repo_name(remote_url)
163
+ path = if remote_url.match?(%r{\A\w+://})
164
+ URI.parse(remote_url).path
165
+ else
166
+ # SSH format: git@host:owner/repo.git
167
+ remote_url.split(":").last
168
+ end
169
+ path.delete_prefix("/").delete_suffix(".git")
170
+ rescue URI::InvalidURIError
171
+ remote_url
172
+ end
173
+
174
+ # Queries GitHub for an open PR on the given branch via the gh CLI.
175
+ #
176
+ # @param branch [String] branch name
177
+ # @return [Hash, nil] with :pr_number and :pr_state, or nil
178
+ # @note Returns nil on timeout, missing gh CLI, or JSON parse errors
179
+ def detect_pr(branch)
180
+ Timeout.timeout(Anima::Settings.web_request_timeout) do
181
+ output, status = Open3.capture2(
182
+ "gh", "pr", "list", "--head", branch,
183
+ "--json", "number,state", "--limit", "1",
184
+ chdir: @pwd
185
+ )
186
+ return unless status.success?
187
+
188
+ pr = JSON.parse(output).first
189
+ return unless pr
190
+
191
+ {pr_number: pr["number"], pr_state: pr["state"].downcase}
192
+ end
193
+ rescue Timeout::Error, Errno::ENOENT, JSON::ParserError
194
+ nil
195
+ end
196
+
197
+ # Scans for well-known project files up to a configurable depth.
198
+ #
199
+ # @return [String, nil] project files section, or nil when none found
200
+ def project_files_section
201
+ found = scan_project_files
202
+ return if found.empty?
203
+
204
+ header = "Project files that may contain useful context:"
205
+ entries = found.map { |path| "- #{path}" }
206
+ [header, *entries, "Use read_file to examine these when needed."].join("\n")
207
+ end
208
+
209
+ # Scans the working directory for whitelisted filenames.
210
+ #
211
+ # @return [Array<String>] sorted relative paths
212
+ def scan_project_files
213
+ base = Pathname.new(@pwd)
214
+
215
+ glob_patterns.flat_map { |pattern| Dir.glob(pattern) }
216
+ .map { |full_path| Pathname.new(full_path).relative_path_from(base).to_s }
217
+ .sort
218
+ .uniq
219
+ end
220
+
221
+ # Builds glob patterns for each whitelisted filename at each depth level.
222
+ #
223
+ # @return [Array<String>] glob patterns
224
+ def glob_patterns
225
+ whitelist = Anima::Settings.project_files_whitelist
226
+ max_depth = Anima::Settings.project_files_max_depth
227
+
228
+ whitelist.product((0..max_depth).to_a).map do |filename, depth|
229
+ File.join(@pwd, Array.new(depth, "*"), filename)
230
+ end
231
+ end
232
+ end
@@ -42,6 +42,7 @@ module Events
42
42
  target_session.events.create!(
43
43
  event_type: event_type,
44
44
  payload: payload,
45
+ status: payload[:status],
45
46
  tool_use_id: payload[:tool_use_id],
46
47
  timestamp: payload[:timestamp] || Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
47
48
  )
@@ -4,8 +4,25 @@ module Events
4
4
  class UserMessage < Base
5
5
  TYPE = "user_message"
6
6
 
7
+ # @return [String, nil] "pending" when queued during active processing, nil otherwise
8
+ attr_reader :status
9
+
10
+ # @param content [String] message text
11
+ # @param session_id [Integer, nil] session identifier
12
+ # @param status [String, nil] "pending" when queued during active agent processing
13
+ def initialize(content:, session_id: nil, status: nil)
14
+ super(content: content, session_id: session_id)
15
+ @status = status
16
+ end
17
+
7
18
  def type
8
19
  TYPE
9
20
  end
21
+
22
+ def to_h
23
+ h = super
24
+ h[:status] = status if status
25
+ h
26
+ end
10
27
  end
11
28
  end
data/lib/llm/client.rb CHANGED
@@ -15,10 +15,6 @@ module LLM
15
15
  # registry.register(Tools::WebGet)
16
16
  # client.chat_with_tools(messages, registry: registry, session_id: session.id)
17
17
  class Client
18
- DEFAULT_MODEL = "claude-sonnet-4-20250514"
19
- DEFAULT_MAX_TOKENS = 8192
20
- MAX_TOOL_ROUNDS = 25
21
-
22
18
  # @return [Providers::Anthropic] the underlying API provider
23
19
  attr_reader :provider
24
20
 
@@ -28,14 +24,16 @@ module LLM
28
24
  # @return [Integer] maximum tokens in the response
29
25
  attr_reader :max_tokens
30
26
 
31
- # @param model [String] Anthropic model identifier
32
- # @param max_tokens [Integer] maximum tokens in the response
27
+ # @param model [String] Anthropic model identifier (default from Settings)
28
+ # @param max_tokens [Integer] maximum tokens in the response (default from Settings)
33
29
  # @param provider [Providers::Anthropic, nil] injectable provider instance;
34
30
  # defaults to a new {Providers::Anthropic} using credentials
35
- def initialize(model: DEFAULT_MODEL, max_tokens: DEFAULT_MAX_TOKENS, provider: nil)
31
+ # @param logger [Logger, nil] optional logger for tool call tracing
32
+ def initialize(model: Anima::Settings.model, max_tokens: Anima::Settings.max_tokens, provider: nil, logger: nil)
36
33
  @provider = build_provider(provider)
37
34
  @model = model
38
35
  @max_tokens = max_tokens
36
+ @logger = logger
39
37
  end
40
38
 
41
39
  # Send messages to the LLM and return the assistant's text response.
@@ -75,8 +73,9 @@ module LLM
75
73
 
76
74
  loop do
77
75
  rounds += 1
78
- if rounds > MAX_TOOL_ROUNDS
79
- return "[Tool loop exceeded #{MAX_TOOL_ROUNDS} rounds halting]"
76
+ max_rounds = Anima::Settings.max_tool_rounds
77
+ if rounds > max_rounds
78
+ return "[Tool loop exceeded #{max_rounds} rounds — halting]"
80
79
  end
81
80
 
82
81
  response = provider.create_message(
@@ -87,6 +86,8 @@ module LLM
87
86
  **options
88
87
  )
89
88
 
89
+ log(:debug, "stop_reason=#{response["stop_reason"]} content_types=#{(response["content"] || []).map { |b| b["type"] }.join(",")}")
90
+
90
91
  if response["stop_reason"] == "tool_use"
91
92
  tool_results = execute_tools(response, registry, session_id)
92
93
 
@@ -132,18 +133,30 @@ module LLM
132
133
  end
133
134
  end
134
135
 
136
+ # Executes a single tool and always returns a tool_result — even if
137
+ # the tool raises. The LLM requires every tool_use to have a matching
138
+ # tool_result; a missing result breaks the conversation permanently.
135
139
  def execute_single_tool(tool_use, registry, session_id)
136
140
  name = tool_use["name"]
137
141
  id = tool_use["id"]
138
142
  input = tool_use["input"] || {}
139
143
 
144
+ log(:debug, "tool_call: #{name}(#{input.to_json})")
145
+
140
146
  Events::Bus.emit(Events::ToolCall.new(
141
147
  content: "Calling #{name}", tool_name: name,
142
148
  tool_input: input, tool_use_id: id, session_id: session_id
143
149
  ))
144
150
 
145
- result = registry.execute(name, input)
151
+ result = begin
152
+ registry.execute(name, input)
153
+ rescue => error
154
+ Rails.logger.error("Tool #{name} raised #{error.class}: #{error.message}")
155
+ {error: "#{error.class}: #{error.message}"}
156
+ end
157
+
146
158
  result_content = format_tool_result(result)
159
+ log(:debug, "tool_result: #{name} → #{result_content.to_s.truncate(200)}")
147
160
 
148
161
  Events::Bus.emit(Events::ToolResponse.new(
149
162
  content: result_content, tool_name: name, tool_use_id: id,
@@ -154,6 +167,12 @@ module LLM
154
167
  {type: "tool_result", tool_use_id: id, content: result_content}
155
168
  end
156
169
 
170
+ def log(level, message)
171
+ return unless @logger
172
+
173
+ @logger.public_send(level, message)
174
+ end
175
+
157
176
  def format_tool_result(result)
158
177
  result.is_a?(Hash) ? result.to_json : result.to_s
159
178
  end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+
5
+ module Mcp
6
+ # Manages MCP client connections and registers their tools with
7
+ # {Tools::Registry}. Each configured server (HTTP or stdio) gets
8
+ # a dedicated {MCP::Client} instance. Tool lists are fetched once
9
+ # during registration and cached in the registry — subsequent LLM
10
+ # turns reuse the same tool set without re-querying servers.
11
+ #
12
+ # Connection failures are logged and skipped — a misconfigured or
13
+ # unavailable server does not prevent other servers or built-in
14
+ # tools from working.
15
+ #
16
+ # @example
17
+ # manager = Mcp::ClientManager.new
18
+ # manager.register_tools(registry)
19
+ class ClientManager
20
+ # @param config [Mcp::Config] injectable config for testing
21
+ def initialize(config: Config.new(logger: Rails.logger))
22
+ @config = config
23
+ end
24
+
25
+ # Connects to all configured MCP servers and registers their tools
26
+ # in the given registry. Returns warnings for servers that failed
27
+ # to load so the caller can surface them to the user.
28
+ #
29
+ # @param registry [Tools::Registry] the registry to add tools to
30
+ # @return [Array<String>] warning messages for servers that failed
31
+ def register_tools(registry)
32
+ warnings = []
33
+ register_transport_tools(@config.http_servers, registry, warnings) { |server| build_http_client(server) }
34
+ register_transport_tools(@config.stdio_servers, registry, warnings) { |server| build_stdio_client(server) }
35
+ @config.warnings + warnings
36
+ end
37
+
38
+ private
39
+
40
+ # Iterates server configs, builds a client for each via the block,
41
+ # and registers the server's tools. Failures are logged and collected.
42
+ #
43
+ # @param servers [Array<Hash>] server configs from {Mcp::Config}
44
+ # @param registry [Tools::Registry] registry to register tools in
45
+ # @param warnings [Array<String>] collects failure messages
46
+ # @yield [server] block that builds an {MCP::Client} for the server
47
+ def register_transport_tools(servers, registry, warnings)
48
+ servers.each do |server|
49
+ client = yield(server)
50
+ register_server_tools(server[:name], client, registry)
51
+ rescue => error
52
+ message = "MCP: failed to load tools from #{server[:name]}: #{error.message}"
53
+ Rails.logger.warn(message)
54
+ warnings << message
55
+ end
56
+ end
57
+
58
+ # Fetches tools from an MCP client and registers them with
59
+ # namespaced names in the registry.
60
+ #
61
+ # @param server_name [String] server name for tool namespacing
62
+ # @param client [MCP::Client] connected MCP client
63
+ # @param registry [Tools::Registry] registry to register tools in
64
+ def register_server_tools(server_name, client, registry)
65
+ count = client.tools.map { |mcp_tool|
66
+ Tools::McpTool.new(server_name: server_name, mcp_client: client, mcp_tool: mcp_tool)
67
+ }.each { |wrapper| registry.register(wrapper) }.size
68
+
69
+ Rails.logger.info("MCP: registered #{count} tools from #{server_name}")
70
+ end
71
+
72
+ # @param server [Hash] server config with +:url+ and +:headers+
73
+ # @return [MCP::Client]
74
+ def build_http_client(server)
75
+ transport = MCP::Client::HTTP.new(url: server[:url], headers: server[:headers])
76
+ MCP::Client.new(transport: transport)
77
+ end
78
+
79
+ # @param server [Hash] server config with +:command+, +:args+, +:env+
80
+ # @return [MCP::Client]
81
+ def build_stdio_client(server)
82
+ transport = StdioTransport.new(command: server[:command], args: server[:args], env: server[:env])
83
+ MCP::Client.new(transport: transport)
84
+ end
85
+ end
86
+ end
data/lib/mcp/config.rb ADDED
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "toml-rb"
5
+
6
+ module Mcp
7
+ # Reads and writes MCP server configuration from a TOML file at
8
+ # {DEFAULT_PATH}. Supports HTTP and stdio transports. Secrets stored
9
+ # in Rails encrypted credentials are interpolated via
10
+ # +${credential:key_name}+ syntax in any string value.
11
+ #
12
+ # @example Config file format (~/.anima/mcp.toml)
13
+ # [servers.mythonix]
14
+ # transport = "http"
15
+ # url = "http://localhost:3000/mcp/v2"
16
+ #
17
+ # [servers.linear]
18
+ # transport = "http"
19
+ # url = "https://mcp.linear.app/mcp"
20
+ # headers = { Authorization = "Bearer ${credential:linear_api_key}" }
21
+ #
22
+ # [servers.filesystem]
23
+ # transport = "stdio"
24
+ # command = "mcp-server-filesystem"
25
+ # args = ["--root", "/workspace"]
26
+ class Config
27
+ DEFAULT_PATH = File.expand_path("~/.anima/mcp.toml")
28
+
29
+ # Pattern matching `${credential:key_name}` for credential interpolation.
30
+ CREDENTIAL_PATTERN = /\$\{credential:(\w+)\}/
31
+
32
+ # Bare TOML keys: letters, digits, hyphens, underscores.
33
+ VALID_NAME_PATTERN = /\A[A-Za-z0-9_-]+\z/
34
+
35
+ # Warnings accumulated during parsing (missing credentials, invalid entries).
36
+ # @return [Array<String>]
37
+ attr_reader :warnings
38
+
39
+ # @param path [String] path to the TOML config file
40
+ # @param logger [#warn, nil] optional logger for warning output
41
+ def initialize(path: DEFAULT_PATH, logger: nil)
42
+ @path = path
43
+ @logger = logger
44
+ @warnings = []
45
+ @config_cache = nil
46
+ end
47
+
48
+ # Returns all configured servers with raw (pre-interpolation) settings.
49
+ # Intended for display in CLI commands where showing literal
50
+ # +${credential:...}+ placeholders is more useful than resolved values.
51
+ #
52
+ # @return [Array<Hash>] servers with string keys from TOML plus +"name"+
53
+ def all_servers
54
+ servers = load_config["servers"] || {}
55
+ servers.map { |name, settings| settings.merge("name" => name) }
56
+ end
57
+
58
+ # Adds a server entry to the configuration file.
59
+ # Creates the file and parent directories if they don't exist.
60
+ #
61
+ # @param name [String] unique server identifier (letters, digits, hyphens, underscores)
62
+ # @param settings [Hash<String, Object>] server configuration (transport, url/command, etc.)
63
+ # @raise [ArgumentError] if name is invalid or already exists
64
+ def add_server(name, settings)
65
+ validate_name!(name)
66
+ config = load_config
67
+ servers = config["servers"] ||= {}
68
+
69
+ raise ArgumentError, "server '#{name}' already exists" if servers.key?(name)
70
+
71
+ servers[name] = settings
72
+ write_config(config)
73
+ end
74
+
75
+ # Removes a server entry from the configuration file.
76
+ #
77
+ # @param name [String] server identifier to remove
78
+ # @raise [ArgumentError] if server name not found
79
+ def remove_server(name)
80
+ config = load_config
81
+ servers = config["servers"] || {}
82
+
83
+ raise ArgumentError, "server '#{name}' not found" unless servers.key?(name)
84
+
85
+ servers.delete(name)
86
+ write_config(config)
87
+ end
88
+
89
+ # Returns HTTP server configurations from the config file.
90
+ #
91
+ # @return [Array<Hash>] server configs with +:name+, +:url+, +:headers+ keys
92
+ def http_servers
93
+ servers_by_transport("http") do |name, settings|
94
+ url = settings["url"]
95
+ unless url
96
+ warn_and_skip("server '#{name}' has transport=http but no url")
97
+ next
98
+ end
99
+
100
+ {
101
+ name: name,
102
+ url: interpolate_credentials(url),
103
+ headers: interpolate_hash_values(settings["headers"] || {})
104
+ }
105
+ end
106
+ end
107
+
108
+ # Returns stdio server configurations from the config file.
109
+ #
110
+ # @return [Array<Hash>] server configs with +:name+, +:command+, +:args+, +:env+ keys
111
+ def stdio_servers
112
+ servers_by_transport("stdio") do |name, settings|
113
+ command = settings["command"]
114
+ unless command
115
+ warn_and_skip("server '#{name}' has transport=stdio but no command")
116
+ next
117
+ end
118
+
119
+ {
120
+ name: name,
121
+ command: interpolate_credentials(command),
122
+ args: (settings["args"] || []).map { |arg| interpolate_credentials(arg) },
123
+ env: interpolate_hash_values(settings["env"] || {})
124
+ }
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ # Reads the TOML config file, returning an empty hash when missing.
131
+ # Result is cached for the lifetime of this Config instance; mutating
132
+ # callers (+add_server+, +remove_server+) invalidate the cache on write.
133
+ #
134
+ # @return [Hash] parsed TOML config
135
+ def load_config
136
+ @config_cache ||= if File.exist?(@path)
137
+ TomlRB.load_file(@path)
138
+ else
139
+ {}
140
+ end
141
+ end
142
+
143
+ # Serializes config hash to TOML and writes to disk.
144
+ # Creates parent directories if needed. Sets restrictive permissions
145
+ # since config may contain API keys or auth headers.
146
+ def write_config(config)
147
+ FileUtils.mkdir_p(File.dirname(@path))
148
+ File.write(@path, TomlRB.dump(config))
149
+ File.chmod(0o600, @path)
150
+ @config_cache = nil
151
+ end
152
+
153
+ # @raise [ArgumentError] if name contains characters invalid for TOML bare keys
154
+ def validate_name!(name)
155
+ return if name.match?(VALID_NAME_PATTERN)
156
+
157
+ raise ArgumentError,
158
+ "invalid server name '#{name}' — use only letters, numbers, hyphens, and underscores"
159
+ end
160
+
161
+ # Iterates servers matching a given transport type, yielding each
162
+ # for transport-specific parsing. Servers referencing missing
163
+ # credentials are skipped with a warning — one bad server config
164
+ # must not prevent others from loading.
165
+ #
166
+ # @param transport [String] transport type to filter by ("http", "stdio")
167
+ # @yield [name, settings] block that returns a parsed server hash or nil
168
+ # @return [Array<Hash>] parsed server configs
169
+ def servers_by_transport(transport)
170
+ servers = load_config["servers"] || {}
171
+
172
+ servers.filter_map do |name, settings|
173
+ next unless settings["transport"] == transport
174
+
175
+ yield(name, settings)
176
+ rescue KeyError => error
177
+ warn_and_skip("server '#{name}' references missing credential #{error.message}")
178
+ nil
179
+ end
180
+ end
181
+
182
+ # Logs a warning and collects it for the caller to surface.
183
+ def warn_and_skip(detail)
184
+ message = "MCP: #{detail} — skipping"
185
+ @logger&.warn(message)
186
+ @warnings << message
187
+ end
188
+
189
+ # Replaces +${credential:key_name}+ placeholders with values from
190
+ # Rails encrypted credentials via {Mcp::Secrets}.
191
+ #
192
+ # @param value [String] string potentially containing placeholders
193
+ # @return [String] interpolated string
194
+ # @raise [KeyError] if a referenced credential is not stored
195
+ def interpolate_credentials(value)
196
+ Anima.boot_rails!
197
+ require_relative "secrets"
198
+
199
+ value.gsub(CREDENTIAL_PATTERN) do
200
+ key = ::Regexp.last_match(1)
201
+ Secrets.get(key) || raise(KeyError, key)
202
+ end
203
+ end
204
+
205
+ # Interpolates credentials in all values of a string hash.
206
+ #
207
+ # @param hash [Hash<String, String>] key-value pairs with potential placeholders
208
+ # @return [Hash<String, String>] hash with interpolated values
209
+ def interpolate_hash_values(hash)
210
+ hash.transform_values { |value| interpolate_credentials(value) }
211
+ end
212
+ end
213
+ end