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,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Skills
4
+ # Loads skill definitions from Markdown files and provides lookup.
5
+ # Supports two formats:
6
+ # - Flat file: skills/skill-name.md
7
+ # - Directory: skills/skill-name/SKILL.md (with optional references/ and examples/)
8
+ # Scans two directories:
9
+ # 1. Built-in skills shipped with Anima (skills/ in the gem root)
10
+ # 2. User-defined skills (~/.anima/skills/)
11
+ # User skills override built-in ones when names collide.
12
+ class Registry
13
+ # @return [Hash{String => Definition}] loaded definitions keyed by name
14
+ attr_reader :skills
15
+
16
+ BUILTIN_DIR = File.expand_path("../../skills", __dir__).freeze
17
+ USER_DIR = File.expand_path("~/.anima/skills").freeze
18
+
19
+ def initialize
20
+ @skills = {}
21
+ end
22
+
23
+ # Returns the global registry, lazily loaded on first access.
24
+ #
25
+ # @return [Registry]
26
+ def self.instance
27
+ @instance ||= new.load_all
28
+ end
29
+
30
+ # Reloads the global registry from disk.
31
+ #
32
+ # @return [Registry]
33
+ def self.reload!
34
+ @instance = new.load_all
35
+ end
36
+
37
+ # Loads definitions from both built-in and user directories.
38
+ # User definitions override built-in ones with the same name.
39
+ #
40
+ # @return [self]
41
+ def load_all
42
+ load_directory(BUILTIN_DIR)
43
+ load_directory(USER_DIR)
44
+ self
45
+ end
46
+
47
+ # Loads skill definitions from a single directory.
48
+ # Supports flat files (*.md) and directory-based skills (*/SKILL.md).
49
+ #
50
+ # @param dir [String] directory path to scan for skill definitions
51
+ # (flat .md files and SKILL.md inside subdirectories)
52
+ # @return [void]
53
+ def load_directory(dir)
54
+ return unless Dir.exist?(dir)
55
+
56
+ skill_files(dir).each do |path|
57
+ definition = Definition.from_file(path)
58
+ @skills[definition.name] = definition
59
+ rescue InvalidDefinitionError => error
60
+ Rails.logger.warn("Skipping invalid skill definition #{path}: #{error.message}")
61
+ end
62
+ end
63
+
64
+ # Looks up a named skill definition.
65
+ #
66
+ # @param name [String] skill name
67
+ # @return [Definition, nil]
68
+ def find(name)
69
+ @skills[name]
70
+ end
71
+
72
+ # Skill names and descriptions for inclusion in the analytical brain's context.
73
+ #
74
+ # @return [Hash{String => String}] name => description
75
+ def catalog
76
+ @skills.transform_values(&:description)
77
+ end
78
+
79
+ # @return [Array<String>] registered skill names
80
+ def available_names
81
+ @skills.keys
82
+ end
83
+
84
+ # @return [Boolean]
85
+ def any?
86
+ @skills.any?
87
+ end
88
+
89
+ # @return [Integer]
90
+ def size
91
+ @skills.size
92
+ end
93
+
94
+ private
95
+
96
+ # Finds all skill definition files in a directory — both flat .md files
97
+ # and SKILL.md files inside subdirectories.
98
+ #
99
+ # @param dir [String] directory to scan
100
+ # @return [Array<String>] sorted paths to skill definition files
101
+ def skill_files(dir)
102
+ Dir.glob([File.join(dir, "*.md"), File.join(dir, "*/SKILL.md")]).sort
103
+ end
104
+ end
105
+ end
data/lib/tools/edit.rb ADDED
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Performs surgical text replacement with uniqueness constraint.
5
+ # Finds old_text in the file (must match exactly one location), replaces
6
+ # with new_text, and returns a unified diff. Falls back to
7
+ # whitespace-normalized fuzzy matching when exact match fails.
8
+ #
9
+ # Normalizes BOM and CRLF line endings for matching, restoring them after
10
+ # the edit. Rejects ambiguous edits where old_text matches zero or
11
+ # multiple locations.
12
+ #
13
+ # @example Replacing a method body
14
+ # tool.execute("path" => "app.rb",
15
+ # "old_text" => "def greet\n 'hi'\nend",
16
+ # "new_text" => "def greet\n 'hello'\nend")
17
+ # # => "--- app.rb\n+++ app.rb\n@@ -1,3 +1,3 @@\n ..."
18
+ class Edit < Base
19
+ def self.tool_name = "edit"
20
+
21
+ def self.description = "Replace exact text in a file. old_text must match exactly one location; " \
22
+ "include surrounding lines for uniqueness. Use for surgical edits; " \
23
+ "use write for new files or full replacement."
24
+
25
+ def self.input_schema
26
+ {
27
+ type: "object",
28
+ properties: {
29
+ path: {type: "string", description: "Absolute or relative file path (relative resolved against working directory)"},
30
+ old_text: {type: "string", description: "Exact text to find (must match exactly one location — include surrounding context if needed)"},
31
+ new_text: {type: "string", description: "Replacement text (empty string to delete)"}
32
+ },
33
+ required: %w[path old_text new_text]
34
+ }
35
+ end
36
+
37
+ # @param shell_session [ShellSession, nil] provides working directory for resolving relative paths
38
+ def initialize(shell_session: nil, **)
39
+ @working_directory = shell_session&.pwd
40
+ end
41
+
42
+ # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
43
+ # @return [String] unified diff showing the change
44
+ # @return [Hash] with :error key on failure
45
+ def execute(input)
46
+ path, old_text, new_text = extract_params(input)
47
+ return {error: "Path cannot be blank"} if path.empty?
48
+ return {error: "old_text cannot be blank"} if old_text.empty?
49
+
50
+ path = resolve_path(path)
51
+
52
+ error = validate_file(path)
53
+ return error if error
54
+
55
+ edit_file(path, old_text, new_text)
56
+ end
57
+
58
+ private
59
+
60
+ def extract_params(input)
61
+ path = input["path"].to_s.strip
62
+ old_text = input["old_text"].to_s
63
+ new_text = input["new_text"].to_s
64
+ [path, old_text, new_text]
65
+ end
66
+
67
+ def resolve_path(path)
68
+ if @working_directory
69
+ File.expand_path(path, @working_directory)
70
+ else
71
+ File.expand_path(path)
72
+ end
73
+ end
74
+
75
+ def validate_file(path)
76
+ return {error: "File not found: #{path}"} unless File.exist?(path)
77
+ return {error: "Is a directory: #{path}"} if File.directory?(path)
78
+ return {error: "Permission denied: #{path}"} unless File.readable?(path) && File.writable?(path)
79
+ size = File.size(path)
80
+ max_size = Anima::Settings.max_file_size
81
+ if size > max_size
82
+ {error: "File is #{size} bytes (#{size / 1_048_576} MB). " \
83
+ "Max editable size is #{max_size / 1_048_576} MB. Use bash tool with sed instead."}
84
+ end
85
+ end
86
+
87
+ def edit_file(path, old_text, new_text)
88
+ raw = File.binread(path)
89
+ bom = extract_bom(raw)
90
+ content = raw[bom.length..].force_encoding("UTF-8")
91
+ had_crlf = content.include?("\r\n")
92
+ normalized = had_crlf ? content.gsub("\r\n", "\n") : content
93
+
94
+ match = find_unique_match(normalized, old_text, path)
95
+ return match if match.is_a?(Hash)
96
+
97
+ position, matched_text, fuzzy = match
98
+ new_content = normalized[0...position] + new_text + normalized[(position + matched_text.length)..]
99
+
100
+ if normalized == new_content
101
+ return {error: "old_text and new_text are identical. No changes made to #{path}."}
102
+ end
103
+
104
+ output = had_crlf ? new_content.gsub("\n", "\r\n") : new_content
105
+ File.binwrite(path, bom + output.b)
106
+
107
+ build_diff(path, normalized, new_content, fuzzy)
108
+ rescue Errno::EACCES
109
+ {error: "Permission denied: #{path}"}
110
+ rescue Errno::ENOSPC
111
+ {error: "No space left on device: #{path}"}
112
+ rescue Errno::EROFS
113
+ {error: "Read-only file system: #{path}"}
114
+ end
115
+
116
+ # @return [String] UTF-8 BOM bytes if present, empty binary string otherwise
117
+ def extract_bom(raw)
118
+ bytes = raw.b
119
+ bytes.start_with?("\xEF\xBB\xBF".b) ? bytes[0, 3] : "".b
120
+ end
121
+
122
+ # Finds exactly one match for old_text in content.
123
+ # Tries exact match first, then whitespace-normalized fuzzy match.
124
+ # @return [Array(Integer, String, Boolean)] position, matched text, fuzzy flag
125
+ # @return [Hash] error hash if zero or multiple matches found
126
+ def find_unique_match(content, old_text, path)
127
+ exact = find_all_positions(content, old_text)
128
+ return [exact[0], old_text, false] if exact.one?
129
+ return ambiguity_error(exact, content, path) if exact.length > 1
130
+
131
+ fuzzy = find_fuzzy_matches(content, old_text)
132
+ return [fuzzy[0][0], fuzzy[0][1], true] if fuzzy.one?
133
+ return ambiguity_error(fuzzy.map(&:first), content, path, fuzzy: true) if fuzzy.length > 1
134
+
135
+ {error: "Could not find old_text in #{path}. " \
136
+ "Verify the text exists and matches exactly (including whitespace). " \
137
+ "Use the read tool to check current file contents."}
138
+ end
139
+
140
+ def ambiguity_error(positions, content, path, fuzzy: false)
141
+ kind = fuzzy ? "fuzzy matches" : "matches"
142
+ line_numbers = positions.map { |pos| line_number_at(content, pos) }
143
+ {error: "Found #{positions.length} #{kind} for old_text in #{path}. " \
144
+ "Provide more surrounding context to uniquely identify the location. " \
145
+ "Matches at lines: #{line_numbers.join(", ")}"}
146
+ end
147
+
148
+ def line_number_at(content, position)
149
+ content[0...position].count("\n") + 1
150
+ end
151
+
152
+ def find_all_positions(content, text)
153
+ positions = []
154
+ offset = 0
155
+ while (pos = content.index(text, offset))
156
+ positions << pos
157
+ offset = pos + 1
158
+ end
159
+ positions
160
+ end
161
+
162
+ # Finds old_text in content using whitespace-normalized line comparison.
163
+ # @return [Array<Array(Integer, String)>] array of [position, matched_text] pairs
164
+ def find_fuzzy_matches(content, old_text)
165
+ content_lines = content.split("\n", -1)
166
+ search_lines = old_text.split("\n", -1)
167
+ search_lines.pop if search_lines.last&.empty? && old_text.end_with?("\n")
168
+ trailing_newline = old_text.end_with?("\n")
169
+
170
+ normalized_search = search_lines.map { |line| collapse_whitespace(line) }
171
+ return [] if normalized_search.all?(&:empty?)
172
+
173
+ window_size = search_lines.length
174
+ matches = []
175
+ (0..content_lines.length - window_size).each do |start_idx|
176
+ window = content_lines[start_idx, window_size]
177
+ next unless window.map { |line| collapse_whitespace(line) } == normalized_search
178
+
179
+ pos = start_idx.zero? ? 0 : content_lines[0...start_idx].sum { |line| line.length + 1 }
180
+ matched = window.join("\n")
181
+ matched += "\n" if trailing_newline
182
+ matches << [pos, matched]
183
+ end
184
+
185
+ matches
186
+ end
187
+
188
+ def collapse_whitespace(text)
189
+ text.gsub(/[[:blank:]]+/, " ").strip
190
+ end
191
+
192
+ # Generates a unified diff between old and new content with 3 lines of context.
193
+ DIFF_CONTEXT = 3
194
+
195
+ def build_diff(path, old_content, new_content, fuzzy)
196
+ before = old_content.lines(chomp: true)
197
+ after = new_content.lines(chomp: true)
198
+
199
+ first = 0
200
+ first += 1 while first < before.length && first < after.length && before[first] == after[first]
201
+
202
+ old_end = before.length - 1
203
+ new_end = after.length - 1
204
+ while old_end > first && new_end > first && before[old_end] == after[new_end]
205
+ old_end -= 1
206
+ new_end -= 1
207
+ end
208
+
209
+ ctx_start = [first - DIFF_CONTEXT, 0].max
210
+ old_ctx_end = [old_end + DIFF_CONTEXT, before.length - 1].min
211
+ new_ctx_end = [new_end + DIFF_CONTEXT, after.length - 1].min
212
+
213
+ hunk = []
214
+ hunk << "--- #{path}"
215
+ hunk << "+++ #{path}"
216
+ hunk << "@@ -#{ctx_start + 1},#{old_ctx_end - ctx_start + 1} +#{ctx_start + 1},#{new_ctx_end - ctx_start + 1} @@"
217
+ (ctx_start...first).each { |idx| hunk << " #{before[idx]}" }
218
+ (first..old_end).each { |idx| hunk << "-#{before[idx]}" }
219
+ (first..new_end).each { |idx| hunk << "+#{after[idx]}" }
220
+ ((old_end + 1)..old_ctx_end).each { |idx| hunk << " #{before[idx]}" }
221
+
222
+ diff = hunk.join("\n")
223
+ fuzzy ? "(fuzzy match — whitespace differences were ignored)\n#{diff}" : diff
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Wraps a single MCP server tool for use with {Tools::Registry}.
5
+ # Registered as an instance (not a class) — the Registry calls
6
+ # +#execute+ directly without instantiation, since MCP tools
7
+ # carry their own client reference and are effectively stateless
8
+ # from the LLM's perspective.
9
+ #
10
+ # Implements the same duck-typed interface as {Tools::Base} subclasses:
11
+ # - +#tool_name+ — unique identifier
12
+ # - +#description+ — human-readable description
13
+ # - +#input_schema+ — JSON Schema for parameters
14
+ # - +#schema+ — Anthropic API tool definition
15
+ # - +#execute(input)+ — run the tool
16
+ #
17
+ # Tool names are namespaced as `<server_name>__<tool_name>` to prevent
18
+ # collisions between servers and with built-in tools.
19
+ #
20
+ # @example
21
+ # wrapper = Tools::McpTool.new(
22
+ # server_name: "mythonix",
23
+ # mcp_client: client,
24
+ # mcp_tool: client.tools.first
25
+ # )
26
+ # wrapper.tool_name # => "mythonix__create_image"
27
+ # wrapper.execute({"prompt" => "a red dragon"})
28
+ class McpTool
29
+ # Separator between server name and tool name in namespaced identifiers.
30
+ NAMESPACE_SEPARATOR = "__"
31
+
32
+ # @return [String] namespaced tool identifier (<server>__<tool>)
33
+ attr_reader :tool_name
34
+
35
+ # @param server_name [String] MCP server name from config
36
+ # @param mcp_client [MCP::Client] the client instance for this server
37
+ # @param mcp_tool [MCP::Client::Tool] tool metadata from the server
38
+ def initialize(server_name:, mcp_client:, mcp_tool:)
39
+ @tool_name = "#{server_name}#{NAMESPACE_SEPARATOR}#{mcp_tool.name}"
40
+ @mcp_client = mcp_client
41
+ @mcp_tool = mcp_tool
42
+ end
43
+
44
+ # @return [String] tool description from the MCP server
45
+ def description
46
+ @mcp_tool.description
47
+ end
48
+
49
+ # @return [Hash] JSON Schema for tool input parameters
50
+ def input_schema
51
+ @mcp_tool.input_schema
52
+ end
53
+
54
+ # Builds the schema hash expected by the Anthropic tools API.
55
+ # @return [Hash] with :name, :description, and :input_schema keys
56
+ def schema
57
+ {name: tool_name, description: description, input_schema: input_schema}
58
+ end
59
+
60
+ # Calls the MCP server tool and normalizes the response.
61
+ #
62
+ # @param input [Hash] tool input parameters from the LLM
63
+ # @return [String] normalized tool output
64
+ # @return [Hash] with :error key on failure
65
+ def execute(input)
66
+ response = @mcp_client.call_tool(tool: @mcp_tool, arguments: input)
67
+ normalize_response(response)
68
+ rescue MCP::Client::RequestHandlerError => error
69
+ {error: "#{tool_name}: #{error.message}"}
70
+ end
71
+
72
+ private
73
+
74
+ # Extracts content from an MCP tool response (JSON-RPC envelope).
75
+ # Checks the `isError` flag and routes accordingly.
76
+ #
77
+ # @param response [Hash] full JSON-RPC response from MCP client
78
+ # @return [String] concatenated text content
79
+ # @return [Hash] with :error key if the response indicates an error
80
+ def normalize_response(response)
81
+ result = response["result"] || response
82
+ error = result["isError"]
83
+
84
+ text = extract_text(result)
85
+ error ? {error: "#{tool_name}: #{text}"} : text
86
+ end
87
+
88
+ # Extracts human-readable text from MCP content blocks.
89
+ # MCP responses contain an array of typed content blocks.
90
+ #
91
+ # @param result [Hash] MCP result containing "content" array
92
+ # @return [String] concatenated text from all content blocks
93
+ def extract_text(result)
94
+ content = result["content"]
95
+
96
+ return result.to_json unless content
97
+
98
+ case content
99
+ when Array
100
+ content.filter_map { |block|
101
+ case block["type"]
102
+ when "text" then block["text"]
103
+ when "image" then "[image: #{block["mimeType"]}]"
104
+ else block.to_json
105
+ end
106
+ }.join("\n")
107
+ when String
108
+ content
109
+ else
110
+ content.to_json
111
+ end
112
+ end
113
+ end
114
+ end
data/lib/tools/read.rb ADDED
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tools
4
+ # Reads file contents with smart truncation and offset/limit paging.
5
+ # Returns plain text without line numbers, normalized to LF line endings.
6
+ #
7
+ # Truncation limits: `Anima::Settings.max_read_lines` lines or `Anima::Settings.max_read_bytes` bytes, whichever
8
+ # hits first. When truncated, appends a continuation hint with the next
9
+ # offset value so the agent can page through large files.
10
+ #
11
+ # @example Basic read
12
+ # tool.execute("path" => "config/routes.rb")
13
+ # # => "Rails.application.routes.draw do\n ..."
14
+ #
15
+ # @example Paging through a large file
16
+ # tool.execute("path" => "large.log", "offset" => 2001, "limit" => 500)
17
+ # # => "line 2001 content\n..."
18
+ class Read < Base
19
+ def self.tool_name = "read"
20
+
21
+ def self.description = "Read file contents. Returns plain text with smart truncation. Use offset/limit to page through large files."
22
+
23
+ def self.input_schema
24
+ {
25
+ type: "object",
26
+ properties: {
27
+ path: {type: "string", description: "Absolute or relative file path (relative resolved against working directory)"},
28
+ offset: {type: "integer", description: "1-indexed line number to start from (default: 1)"},
29
+ limit: {type: "integer", description: "Maximum lines to read (subject to line and byte caps from config)"}
30
+ },
31
+ required: ["path"]
32
+ }
33
+ end
34
+
35
+ # @param shell_session [ShellSession, nil] provides working directory for resolving relative paths
36
+ def initialize(shell_session: nil, **)
37
+ @working_directory = shell_session&.pwd
38
+ end
39
+
40
+ # @param input [Hash<String, Object>] string-keyed hash from the Anthropic API
41
+ # @return [String] file contents (possibly truncated with continuation hint)
42
+ # @return [Hash] with :error key on failure
43
+ def execute(input)
44
+ path, offset, limit = extract_params(input)
45
+ return {error: "Path cannot be blank"} if path.empty?
46
+
47
+ path = resolve_path(path)
48
+
49
+ error = validate_file(path)
50
+ return error if error
51
+
52
+ read_file(path, offset, limit)
53
+ end
54
+
55
+ private
56
+
57
+ def extract_params(input)
58
+ path = input["path"].to_s.strip
59
+ offset = [input["offset"].to_i, 1].max
60
+ raw_limit = input["limit"]
61
+ limit = raw_limit ? [raw_limit.to_i, 1].max : Anima::Settings.max_read_lines
62
+ [path, offset, limit]
63
+ end
64
+
65
+ def resolve_path(path)
66
+ if @working_directory
67
+ File.expand_path(path, @working_directory)
68
+ else
69
+ File.expand_path(path)
70
+ end
71
+ end
72
+
73
+ def validate_file(path)
74
+ return {error: "File not found: #{path}"} unless File.exist?(path)
75
+ return {error: "Is a directory: #{path}"} if File.directory?(path)
76
+ {error: "Permission denied: #{path}"} unless File.readable?(path)
77
+ end
78
+
79
+ # Reads the file, normalizes line endings, and applies truncation limits.
80
+ # Two limits are enforced as first-hit-wins: line count and byte size.
81
+ # A single line exceeding `Anima::Settings.max_read_bytes` is rejected outright (likely minified).
82
+ # Files larger than max_file_size are rejected to avoid memory exhaustion.
83
+
84
+ def read_file(path, offset, limit)
85
+ file_size = File.size(path)
86
+ max_size = Anima::Settings.max_file_size
87
+ if file_size > max_size
88
+ return {error: "File is #{file_size} bytes (#{file_size / 1_048_576} MB). " \
89
+ "Max readable size is #{max_size / 1_048_576} MB. " \
90
+ "Use bash tool with: head -n #{offset + limit} #{path} | tail -n +#{offset}"}
91
+ end
92
+
93
+ lines = normalize(File.read(path))
94
+ return "" if lines.empty?
95
+
96
+ start_index = offset - 1
97
+ return "[File has #{lines.size} lines. Offset #{offset} is beyond end of file.]" if start_index >= lines.size
98
+
99
+ window = lines[start_index, [limit, Anima::Settings.max_read_lines].min]
100
+
101
+ error = check_oversized_lines(window, offset, path)
102
+ return error if error
103
+
104
+ build_output(window, lines.size, offset)
105
+ end
106
+
107
+ def normalize(content)
108
+ content.gsub("\r\n", "\n").lines
109
+ end
110
+
111
+ def check_oversized_lines(window, offset, path)
112
+ max_bytes = Anima::Settings.max_read_bytes
113
+ index = window.index { |line| line.bytesize > max_bytes }
114
+ return unless index
115
+
116
+ line_num = offset + index
117
+ {error: "Line #{line_num} exceeds #{max_bytes} bytes (likely minified). " \
118
+ "Use bash tool with: sed -n '#{line_num}p' #{path}"}
119
+ end
120
+
121
+ def build_output(window, total_lines, offset)
122
+ text, count = accumulate_lines(window)
123
+ end_line = offset + count - 1
124
+
125
+ if end_line < total_lines
126
+ text + "\n\n[Showing lines #{offset}-#{end_line} of #{total_lines}. Use offset=#{end_line + 1} to continue.]"
127
+ else
128
+ text
129
+ end
130
+ end
131
+
132
+ # Accumulates lines until the byte cap would be exceeded.
133
+ # @return [Array(String, Integer)] accumulated text and number of lines included
134
+ def accumulate_lines(window)
135
+ max_bytes = Anima::Settings.max_read_bytes
136
+ output = +""
137
+ bytes = 0
138
+ count = 0
139
+
140
+ window.each_with_index do |line, index|
141
+ break if bytes + line.bytesize > max_bytes && index > 0
142
+
143
+ output << line
144
+ bytes += line.bytesize
145
+ count += 1
146
+ end
147
+
148
+ [output, count]
149
+ end
150
+ end
151
+ end
@@ -4,16 +4,17 @@ module Tools
4
4
  class UnknownToolError < StandardError; end
5
5
 
6
6
  # Manages tool registration, schema export, and dispatch.
7
- # Tools are registered by class and looked up by name at execution time.
8
- # An optional context hash is passed to each tool's constructor, allowing
9
- # shared dependencies (e.g. a {ShellSession}) to reach tools that need them.
7
+ # Accepts both tool classes (e.g. {Tools::Base} subclasses) and tool
8
+ # instances (e.g. {Tools::McpTool}) via duck typing. Classes are
9
+ # instantiated with the registry's context on each execution; instances
10
+ # are called directly since they carry their own state.
10
11
  #
11
12
  # @example
12
13
  # registry = Tools::Registry.new(context: {shell_session: my_shell})
13
14
  # registry.register(Tools::Bash)
14
15
  # registry.execute("bash", {"command" => "ls"})
15
16
  class Registry
16
- # @return [Hash{String => Class}] registered tool classes keyed by name
17
+ # @return [Hash{String => Class, Object}] registered tools keyed by name
17
18
  attr_reader :tools
18
19
 
19
20
  # @param context [Hash] keyword arguments forwarded to every tool constructor
@@ -22,11 +23,11 @@ module Tools
22
23
  @context = context
23
24
  end
24
25
 
25
- # Register a tool class. The class must respond to .tool_name.
26
- # @param tool_class [Class<Tools::Base>] the tool class to register
26
+ # Register a tool class or instance. Must respond to +tool_name+ and +schema+.
27
+ # @param tool [Class<Tools::Base>, #tool_name] tool class or duck-typed instance
27
28
  # @return [void]
28
- def register(tool_class)
29
- @tools[tool_class.tool_name] = tool_class
29
+ def register(tool)
30
+ @tools[tool.tool_name] = tool
30
31
  end
31
32
 
32
33
  # @return [Array<Hash>] schema array for the Anthropic tools API parameter
@@ -34,16 +35,17 @@ module Tools
34
35
  @tools.values.map(&:schema)
35
36
  end
36
37
 
37
- # Instantiate and execute a tool by name. The registry's context is
38
- # forwarded to the tool constructor as keyword arguments.
38
+ # Execute a tool by name. Classes are instantiated with the registry's
39
+ # context; instances are called directly.
39
40
  #
40
41
  # @param name [String] registered tool name
41
42
  # @param input [Hash] tool input parameters
42
43
  # @return [String, Hash] tool execution result
43
44
  # @raise [UnknownToolError] if no tool is registered with the given name
44
45
  def execute(name, input)
45
- tool_class = @tools.fetch(name) { raise UnknownToolError, "Unknown tool: #{name}" }
46
- tool_class.new(**@context).execute(input)
46
+ tool = @tools.fetch(name) { raise UnknownToolError, "Unknown tool: #{name}" }
47
+ instance = tool.is_a?(Class) ? tool.new(**@context) : tool
48
+ instance.execute(input)
47
49
  end
48
50
 
49
51
  # @param name [String] tool name to check