anima-core 0.3.0 → 1.0.1

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 (270) hide show
  1. checksums.yaml +4 -4
  2. data/.reek.yml +27 -1
  3. data/CHANGELOG.md +4 -0
  4. data/README.md +219 -25
  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 +4 -1
  11. data/app/channels/session_channel.rb +76 -28
  12. data/app/jobs/agent_request_job.rb +24 -0
  13. data/app/jobs/analytical_brain_job.rb +33 -0
  14. data/app/jobs/count_event_tokens_job.rb +1 -1
  15. data/app/models/concerns/event/broadcasting.rb +20 -2
  16. data/app/models/event.rb +1 -1
  17. data/app/models/goal.rb +91 -0
  18. data/app/models/session.rb +347 -22
  19. data/config/application.rb +2 -0
  20. data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
  21. data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
  22. data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
  23. data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
  24. data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
  25. data/db/migrate/20260315140843_create_goals.rb +16 -0
  26. data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
  27. data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
  28. data/lib/agent_loop.rb +65 -9
  29. data/lib/agents/definition.rb +116 -0
  30. data/lib/agents/registry.rb +106 -0
  31. data/lib/analytical_brain/runner.rb +276 -0
  32. data/lib/analytical_brain/tools/activate_skill.rb +52 -0
  33. data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
  34. data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
  35. data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
  36. data/lib/analytical_brain/tools/finish_goal.rb +62 -0
  37. data/lib/analytical_brain/tools/read_workflow.rb +58 -0
  38. data/lib/analytical_brain/tools/rename_session.rb +63 -0
  39. data/lib/analytical_brain/tools/set_goal.rb +60 -0
  40. data/lib/analytical_brain/tools/update_goal.rb +60 -0
  41. data/lib/analytical_brain.rb +23 -0
  42. data/lib/anima/cli/mcp/secrets.rb +76 -0
  43. data/lib/anima/cli/mcp.rb +197 -0
  44. data/lib/anima/cli.rb +4 -0
  45. data/lib/anima/installer.rb +182 -6
  46. data/lib/anima/settings.rb +226 -0
  47. data/lib/anima/version.rb +1 -1
  48. data/lib/anima.rb +9 -0
  49. data/lib/credential_store.rb +103 -0
  50. data/lib/environment_probe.rb +232 -0
  51. data/lib/llm/client.rb +29 -10
  52. data/lib/mcp/client_manager.rb +86 -0
  53. data/lib/mcp/config.rb +213 -0
  54. data/lib/mcp/health_check.rb +77 -0
  55. data/lib/mcp/secrets.rb +73 -0
  56. data/lib/mcp/stdio_transport.rb +206 -0
  57. data/lib/providers/anthropic.rb +8 -7
  58. data/lib/shell_session.rb +11 -10
  59. data/lib/skills/definition.rb +97 -0
  60. data/lib/skills/registry.rb +105 -0
  61. data/lib/tools/edit.rb +3 -4
  62. data/lib/tools/mcp_tool.rb +114 -0
  63. data/lib/tools/read.rb +15 -16
  64. data/lib/tools/registry.rb +14 -12
  65. data/lib/tools/request_feature.rb +121 -0
  66. data/lib/tools/return_result.rb +81 -0
  67. data/lib/tools/spawn_specialist.rb +109 -0
  68. data/lib/tools/spawn_subagent.rb +111 -0
  69. data/lib/tools/subagent_prompts.rb +12 -0
  70. data/lib/tools/web_get.rb +8 -9
  71. data/lib/tui/app.rb +332 -43
  72. data/lib/tui/message_store.rb +20 -0
  73. data/lib/tui/screens/chat.rb +207 -20
  74. data/lib/workflows/definition.rb +97 -0
  75. data/lib/workflows/registry.rb +89 -0
  76. data/skills/activerecord/SKILL.md +255 -0
  77. data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
  78. data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
  79. data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
  80. data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
  81. data/skills/activerecord/examples/associations/self_referential.rb +302 -0
  82. data/skills/activerecord/examples/associations/through_associations.rb +203 -0
  83. data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
  84. data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
  85. data/skills/activerecord/examples/basics/inheritance.rb +377 -0
  86. data/skills/activerecord/examples/basics/type_casting.rb +317 -0
  87. data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
  88. data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
  89. data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
  90. data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
  91. data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
  92. data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
  93. data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
  94. data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
  95. data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
  96. data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
  97. data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
  98. data/skills/activerecord/examples/querying/optimization.rb +275 -0
  99. data/skills/activerecord/examples/querying/scopes.rb +260 -0
  100. data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
  101. data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
  102. data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
  103. data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
  104. data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
  105. data/skills/activerecord/references/associations.md +709 -0
  106. data/skills/activerecord/references/basics.md +622 -0
  107. data/skills/activerecord/references/callbacks.md +738 -0
  108. data/skills/activerecord/references/migrations.md +657 -0
  109. data/skills/activerecord/references/querying.md +655 -0
  110. data/skills/activerecord/references/validations.md +596 -0
  111. data/skills/dragonruby/SKILL.md +250 -0
  112. data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
  113. data/skills/dragonruby/examples/audio/background_music.rb +29 -0
  114. data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
  115. data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
  116. data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
  117. data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
  118. data/skills/dragonruby/examples/core/hello_world.rb +24 -0
  119. data/skills/dragonruby/examples/core/labels.rb +22 -0
  120. data/skills/dragonruby/examples/core/sprites.rb +35 -0
  121. data/skills/dragonruby/examples/core/state_management.rb +29 -0
  122. data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
  123. data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
  124. data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
  125. data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
  126. data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
  127. data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
  128. data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
  129. data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
  130. data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
  131. data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
  132. data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
  133. data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
  134. data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
  135. data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
  136. data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
  137. data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
  138. data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
  139. data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
  140. data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
  141. data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
  142. data/skills/dragonruby/examples/input/controller_input.rb +28 -0
  143. data/skills/dragonruby/examples/input/directional_input.rb +24 -0
  144. data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
  145. data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
  146. data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
  147. data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
  148. data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
  149. data/skills/dragonruby/examples/rendering/labels.rb +32 -0
  150. data/skills/dragonruby/examples/rendering/layering.rb +51 -0
  151. data/skills/dragonruby/examples/rendering/solids.rb +61 -0
  152. data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
  153. data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
  154. data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
  155. data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
  156. data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
  157. data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
  158. data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
  159. data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
  160. data/skills/dragonruby/references/audio.md +396 -0
  161. data/skills/dragonruby/references/core.md +385 -0
  162. data/skills/dragonruby/references/distribution.md +434 -0
  163. data/skills/dragonruby/references/entities.md +516 -0
  164. data/skills/dragonruby/references/game-logic/persistence.md +386 -0
  165. data/skills/dragonruby/references/game-logic/state.md +389 -0
  166. data/skills/dragonruby/references/input.md +414 -0
  167. data/skills/dragonruby/references/rendering/animation.md +467 -0
  168. data/skills/dragonruby/references/rendering/primitives.md +403 -0
  169. data/skills/dragonruby/references/scenes.md +443 -0
  170. data/skills/draper-decorators/SKILL.md +344 -0
  171. data/skills/draper-decorators/examples/application_decorator.rb +61 -0
  172. data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
  173. data/skills/draper-decorators/examples/model_decorator.rb +152 -0
  174. data/skills/draper-decorators/references/anti-patterns.md +640 -0
  175. data/skills/draper-decorators/references/patterns.md +507 -0
  176. data/skills/draper-decorators/references/testing.md +559 -0
  177. data/skills/gh-issue.md +182 -0
  178. data/skills/mcp-server/SKILL.md +177 -0
  179. data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
  180. data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
  181. data/skills/mcp-server/examples/http_client.rb +48 -0
  182. data/skills/mcp-server/examples/http_server.rb +97 -0
  183. data/skills/mcp-server/examples/rails_integration.rb +88 -0
  184. data/skills/mcp-server/examples/stdio_server.rb +108 -0
  185. data/skills/mcp-server/examples/streaming_client.rb +95 -0
  186. data/skills/mcp-server/references/gotchas.md +183 -0
  187. data/skills/mcp-server/references/prompts.md +98 -0
  188. data/skills/mcp-server/references/resources.md +53 -0
  189. data/skills/mcp-server/references/server.md +140 -0
  190. data/skills/mcp-server/references/tools.md +146 -0
  191. data/skills/mcp-server/references/transport.md +104 -0
  192. data/skills/ratatui-ruby/SKILL.md +315 -0
  193. data/skills/ratatui-ruby/references/core-concepts.md +340 -0
  194. data/skills/ratatui-ruby/references/events.md +387 -0
  195. data/skills/ratatui-ruby/references/frameworks.md +522 -0
  196. data/skills/ratatui-ruby/references/layout.md +423 -0
  197. data/skills/ratatui-ruby/references/styling.md +268 -0
  198. data/skills/ratatui-ruby/references/testing.md +433 -0
  199. data/skills/ratatui-ruby/references/widgets.md +532 -0
  200. data/skills/rspec/SKILL.md +340 -0
  201. data/skills/rspec/examples/core/basic_structure.rb +69 -0
  202. data/skills/rspec/examples/core/configuration.rb +126 -0
  203. data/skills/rspec/examples/core/hooks.rb +126 -0
  204. data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
  205. data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
  206. data/skills/rspec/examples/core/shared_examples.rb +145 -0
  207. data/skills/rspec/examples/factory_bot/associations.rb +314 -0
  208. data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
  209. data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
  210. data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
  211. data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
  212. data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
  213. data/skills/rspec/examples/factory_bot/traits.rb +293 -0
  214. data/skills/rspec/examples/factory_bot/transients.rb +229 -0
  215. data/skills/rspec/examples/matchers/change.rb +115 -0
  216. data/skills/rspec/examples/matchers/collections.rb +154 -0
  217. data/skills/rspec/examples/matchers/comparisons.rb +79 -0
  218. data/skills/rspec/examples/matchers/composing.rb +155 -0
  219. data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
  220. data/skills/rspec/examples/matchers/equality.rb +58 -0
  221. data/skills/rspec/examples/matchers/errors.rb +136 -0
  222. data/skills/rspec/examples/matchers/output.rb +103 -0
  223. data/skills/rspec/examples/matchers/predicates.rb +87 -0
  224. data/skills/rspec/examples/matchers/truthiness.rb +101 -0
  225. data/skills/rspec/examples/matchers/types.rb +82 -0
  226. data/skills/rspec/examples/matchers/yield.rb +147 -0
  227. data/skills/rspec/examples/mocks/any_instance.rb +172 -0
  228. data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
  229. data/skills/rspec/examples/mocks/constants.rb +177 -0
  230. data/skills/rspec/examples/mocks/doubles.rb +139 -0
  231. data/skills/rspec/examples/mocks/expectations.rb +137 -0
  232. data/skills/rspec/examples/mocks/message_chains.rb +173 -0
  233. data/skills/rspec/examples/mocks/ordering.rb +144 -0
  234. data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
  235. data/skills/rspec/examples/mocks/responses.rb +223 -0
  236. data/skills/rspec/examples/mocks/spies.rb +149 -0
  237. data/skills/rspec/examples/mocks/stubbing.rb +133 -0
  238. data/skills/rspec/examples/rails/channels.rb +250 -0
  239. data/skills/rspec/examples/rails/controller_specs.rb +302 -0
  240. data/skills/rspec/examples/rails/helper_specs.rb +245 -0
  241. data/skills/rspec/examples/rails/job_specs.rb +256 -0
  242. data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
  243. data/skills/rspec/examples/rails/matchers.rb +374 -0
  244. data/skills/rspec/examples/rails/model_specs.rb +193 -0
  245. data/skills/rspec/examples/rails/request_specs.rb +275 -0
  246. data/skills/rspec/examples/rails/routing_specs.rb +276 -0
  247. data/skills/rspec/examples/rails/system_specs.rb +294 -0
  248. data/skills/rspec/examples/rails/transactions.rb +254 -0
  249. data/skills/rspec/examples/rails/view_specs.rb +252 -0
  250. data/skills/rspec/references/core.md +816 -0
  251. data/skills/rspec/references/factory_bot.md +641 -0
  252. data/skills/rspec/references/matchers.md +516 -0
  253. data/skills/rspec/references/mocks.md +381 -0
  254. data/skills/rspec/references/rails.md +528 -0
  255. data/templates/soul.md +40 -0
  256. data/workflows/commit.md +45 -0
  257. data/workflows/create_handoff.md +98 -0
  258. data/workflows/create_note.md +82 -0
  259. data/workflows/create_plan.md +457 -0
  260. data/workflows/decompose_ticket.md +109 -0
  261. data/workflows/feature.md +91 -0
  262. data/workflows/implement_plan.md +87 -0
  263. data/workflows/iterate_plan.md +247 -0
  264. data/workflows/research_codebase.md +210 -0
  265. data/workflows/resume_handoff.md +217 -0
  266. data/workflows/review_pr.md +320 -0
  267. data/workflows/thoughts_init.md +71 -0
  268. data/workflows/validate_plan.md +166 -0
  269. metadata +284 -2
  270. data/.mise.toml +0 -2
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcp
4
+ # CRUD operations for MCP server secrets stored in Rails encrypted credentials.
5
+ # Secrets live under the +mcp+ namespace in the credentials file:
6
+ #
7
+ # mcp:
8
+ # linear_api_key: "sk-xxx"
9
+ # mythonix_api_key: "Bearer tok-yyy"
10
+ #
11
+ # Referenced in mcp.toml via +${credential:key_name}+ syntax, resolved at
12
+ # runtime by {Mcp::Config#interpolate_credentials}.
13
+ #
14
+ # @example Storing a secret
15
+ # Mcp::Secrets.set("linear_api_key", "sk-xxx")
16
+ #
17
+ # @example Retrieving a secret
18
+ # Mcp::Secrets.get("linear_api_key") #=> "sk-xxx"
19
+ class Secrets
20
+ NAMESPACE = "mcp"
21
+
22
+ # Keys must be interpolatable via ${credential:key_name} in mcp.toml.
23
+ VALID_KEY_PATTERN = /\A\w+\z/
24
+
25
+ class << self
26
+ # Stores a secret in encrypted credentials.
27
+ #
28
+ # @param key [String] secret identifier (e.g. "linear_api_key")
29
+ # @param value [String] secret value
30
+ # @return [void]
31
+ # @raise [ArgumentError] if key contains characters that cannot be
32
+ # referenced via +${credential:key_name}+ syntax
33
+ def set(key, value)
34
+ validate_key!(key)
35
+ CredentialStore.write(NAMESPACE, key => value)
36
+ end
37
+
38
+ # Retrieves a secret from encrypted credentials.
39
+ #
40
+ # @param key [String] secret identifier
41
+ # @return [String, nil] secret value or nil if not found
42
+ def get(key)
43
+ CredentialStore.read(NAMESPACE, key)
44
+ end
45
+
46
+ # Lists all stored MCP secret keys (not values).
47
+ #
48
+ # @return [Array<String>] secret names
49
+ def list
50
+ CredentialStore.list(NAMESPACE)
51
+ end
52
+
53
+ # Removes a secret from encrypted credentials.
54
+ #
55
+ # @param key [String] secret identifier to remove
56
+ # @return [void]
57
+ def remove(key)
58
+ CredentialStore.remove(NAMESPACE, key)
59
+ end
60
+
61
+ private
62
+
63
+ # @raise [ArgumentError] if key is not interpolatable
64
+ def validate_key!(key)
65
+ return if key.match?(VALID_KEY_PATTERN)
66
+
67
+ raise ArgumentError,
68
+ "invalid secret key '#{key}' — use only letters, numbers, and underscores " \
69
+ "(must match ${credential:key_name} syntax)"
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "mcp"
5
+ require "open3"
6
+ require "timeout"
7
+
8
+ module Mcp
9
+ # Client-side stdio transport for MCP servers that communicate via
10
+ # JSON-RPC over stdin/stdout. Conforms to the MCP SDK transport contract
11
+ # (+send_request(request:)+ → Hash) so it plugs into {MCP::Client}
12
+ # identically to the built-in HTTP transport.
13
+ #
14
+ # Spawns the server process lazily on first request. If the process
15
+ # crashes, the next request automatically respawns it. Thread-safe
16
+ # via a mutex around the entire request/response cycle.
17
+ #
18
+ # @example
19
+ # transport = Mcp::StdioTransport.new(command: "linear-toon-mcp")
20
+ # client = MCP::Client.new(transport: transport)
21
+ # client.tools # spawns process, sends tools/list, returns tools
22
+ #
23
+ # @see MCP::Client::HTTP the built-in HTTP transport this mirrors
24
+ class StdioTransport
25
+ # Seconds to wait for graceful SIGTERM shutdown before escalating to SIGKILL.
26
+ GRACEFUL_SHUTDOWN_TIMEOUT = 2
27
+
28
+ # @param command [String] executable to spawn (resolved via $PATH)
29
+ # @param args [Array<String>] command-line arguments for the server process
30
+ # @param env [Hash<String, String>] environment variables merged into
31
+ # the child process's inherited environment
32
+ def initialize(command:, args: [], env: {})
33
+ @command = command
34
+ @args = args
35
+ @env = env
36
+ @mutex = Mutex.new
37
+ @stdin = nil
38
+ @stdout = nil
39
+ @wait_thread = nil
40
+ end
41
+
42
+ # Sends a JSON-RPC request and returns the parsed response.
43
+ # Spawns the server process on first call. If the process died
44
+ # since the last call, respawns automatically.
45
+ #
46
+ # @param request [Hash] complete JSON-RPC request object with
47
+ # +:jsonrpc+, +:id+, +:method+, and optional +:params+ keys
48
+ # @return [Hash] parsed JSON-RPC response (string keys)
49
+ # @raise [MCP::Client::RequestHandlerError] on transport-level errors
50
+ # (process crash, invalid JSON, timeout, command not found)
51
+ def send_request(request:)
52
+ @mutex.synchronize do
53
+ perform_request(request)
54
+ end
55
+ end
56
+
57
+ # Terminates the server process and releases resources.
58
+ # Safe to call multiple times — subsequent calls are no-ops.
59
+ def shutdown
60
+ @mutex.synchronize { stop_process }
61
+ self.class.unregister(self)
62
+ end
63
+
64
+ # --- Class-level instance tracking for at_exit cleanup ---
65
+
66
+ @instances = []
67
+ @instances_mutex = Mutex.new
68
+
69
+ class << self
70
+ # @api private
71
+ def register(instance)
72
+ @instances_mutex.synchronize { @instances << instance }
73
+ end
74
+
75
+ # @api private
76
+ def unregister(instance)
77
+ @instances_mutex.synchronize { @instances.delete(instance) }
78
+ end
79
+
80
+ # Shuts down all tracked instances. Called automatically via +at_exit+.
81
+ def cleanup_all
82
+ @instances_mutex.synchronize do
83
+ @instances.each { |instance| instance.send(:stop_process) }
84
+ @instances.clear
85
+ end
86
+ end
87
+ end
88
+
89
+ at_exit { Mcp::StdioTransport.cleanup_all }
90
+
91
+ private
92
+
93
+ def perform_request(request)
94
+ ensure_running
95
+ write_request(request)
96
+ read_response(request)
97
+ rescue Errno::EPIPE, IOError => error
98
+ stop_process
99
+ raise_transport_error("Server process crashed: #{error.message}", request, error)
100
+ rescue JSON::ParserError => error
101
+ stop_process
102
+ raise_transport_error("Invalid JSON from server: #{error.message}", request, error)
103
+ rescue Timeout::Error
104
+ stop_process
105
+ raise_transport_error("No response within #{Anima::Settings.mcp_response_timeout}s", request)
106
+ end
107
+
108
+ def ensure_running
109
+ return if alive?
110
+
111
+ spawn_process
112
+ end
113
+
114
+ def alive?
115
+ @wait_thread&.alive? || false
116
+ end
117
+
118
+ def spawn_process
119
+ @stdin, @stdout, @wait_thread = Open3.popen2(@env, @command, *@args)
120
+ @stdin.set_encoding("UTF-8")
121
+ @stdout.set_encoding("UTF-8")
122
+ self.class.register(self)
123
+ rescue Errno::ENOENT => error
124
+ raise_transport_error("Command not found: #{@command}", {}, error)
125
+ end
126
+
127
+ def write_request(request)
128
+ @stdin.puts(JSON.generate(request))
129
+ @stdin.flush
130
+ end
131
+
132
+ # Reads lines from stdout until a JSON-RPC response matching the
133
+ # request ID is found. Notifications (messages without a matching id)
134
+ # are silently skipped — the MCP protocol allows servers to emit
135
+ # them at any time.
136
+ def read_response(request)
137
+ request_id = (request[:id] || request["id"]).to_s
138
+
139
+ Timeout.timeout(Anima::Settings.mcp_response_timeout) do
140
+ loop do
141
+ line = @stdout.gets
142
+ raise IOError, "Server process closed stdout" if line.nil?
143
+
144
+ parsed = JSON.parse(line)
145
+ unless parsed.is_a?(Hash)
146
+ raise JSON::ParserError, "Expected JSON object, got #{parsed.class}"
147
+ end
148
+
149
+ return parsed if parsed["id"].to_s == request_id
150
+ end
151
+ end
152
+ end
153
+
154
+ def stop_process
155
+ close_pipes
156
+ terminate_process
157
+ @stdin = nil
158
+ @stdout = nil
159
+ @wait_thread = nil
160
+ end
161
+
162
+ def close_pipes
163
+ @stdin&.close rescue IOError # rubocop:disable Style/RescueModifier
164
+ @stdout&.close rescue IOError # rubocop:disable Style/RescueModifier
165
+ end
166
+
167
+ # Sends SIGTERM and waits up to 2 seconds for the process to exit.
168
+ # Falls back to SIGKILL if the process does not terminate in time.
169
+ def terminate_process
170
+ return unless @wait_thread
171
+
172
+ pid = @wait_thread.pid
173
+ begin
174
+ Process.kill("TERM", pid)
175
+ rescue Errno::ESRCH, Errno::EPERM
176
+ return
177
+ end
178
+
179
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + GRACEFUL_SHUTDOWN_TIMEOUT
180
+ loop do
181
+ _, status = Process.wait2(pid, Process::WNOHANG)
182
+ break if status
183
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
184
+ Process.kill("KILL", pid) rescue Errno::ESRCH # rubocop:disable Style/RescueModifier
185
+ Process.wait(pid) rescue Errno::ECHILD # rubocop:disable Style/RescueModifier
186
+ break
187
+ end
188
+ sleep 0.05
189
+ end
190
+ rescue Errno::ECHILD, Errno::ESRCH
191
+ # Already reaped
192
+ end
193
+
194
+ def raise_transport_error(message, request, original_error = nil)
195
+ method = request[:method] || request["method"]
196
+ params = request[:params] || request["params"]
197
+
198
+ raise MCP::Client::RequestHandlerError.new(
199
+ message,
200
+ {method: method, params: params},
201
+ error_type: :internal_error,
202
+ original_error: original_error
203
+ )
204
+ end
205
+ end
206
+ end
@@ -7,13 +7,11 @@ module Providers
7
7
  include HTTParty
8
8
 
9
9
  base_uri "https://api.anthropic.com"
10
- default_timeout 30
11
10
 
12
11
  TOKEN_PREFIX = "sk-ant-oat01-"
13
12
  TOKEN_MIN_LENGTH = 80
14
13
  API_VERSION = "2023-06-01"
15
14
  REQUIRED_BETA = "oauth-2025-04-20"
16
- VALIDATION_MODEL = "claude-sonnet-4-20250514"
17
15
 
18
16
  class Error < StandardError; end
19
17
  class AuthenticationError < Error; end
@@ -26,7 +24,7 @@ module Providers
26
24
 
27
25
  class << self
28
26
  def fetch_token
29
- token = Rails.application.credentials.dig(:anthropic, :subscription_token)
27
+ token = CredentialStore.read("anthropic", "subscription_token")
30
28
  raise AuthenticationError, <<~MSG.strip if token.blank?
31
29
  No Anthropic subscription token found in credentials.
32
30
  Use the TUI token setup (Ctrl+a → a) to configure your token.
@@ -66,7 +64,8 @@ module Providers
66
64
  response = self.class.post(
67
65
  "/v1/messages",
68
66
  body: body.to_json,
69
- headers: request_headers
67
+ headers: request_headers,
68
+ timeout: Anima::Settings.api_timeout
70
69
  )
71
70
 
72
71
  handle_response(response)
@@ -88,7 +87,8 @@ module Providers
88
87
  response = self.class.post(
89
88
  "/v1/messages/count_tokens",
90
89
  body: body.to_json,
91
- headers: request_headers
90
+ headers: request_headers,
91
+ timeout: Anima::Settings.api_timeout
92
92
  )
93
93
 
94
94
  result = handle_response(response)
@@ -101,11 +101,12 @@ module Providers
101
101
  response = self.class.post(
102
102
  "/v1/messages",
103
103
  body: {
104
- model: VALIDATION_MODEL,
104
+ model: Anima::Settings.model,
105
105
  messages: [{role: "user", content: "Hi"}],
106
106
  max_tokens: 1
107
107
  }.to_json,
108
- headers: request_headers
108
+ headers: request_headers,
109
+ timeout: Anima::Settings.api_timeout
109
110
  )
110
111
 
111
112
  case response.code
data/lib/shell_session.rb CHANGED
@@ -16,9 +16,6 @@ require "timeout"
16
16
  # # => {stdout: "/tmp", stderr: "", exit_code: 0}
17
17
  # session.finalize
18
18
  class ShellSession
19
- COMMAND_TIMEOUT = 30
20
- MAX_OUTPUT_BYTES = 100_000
21
-
22
19
  # @return [String, nil] current working directory of the shell process
23
20
  attr_reader :pwd
24
21
 
@@ -140,12 +137,14 @@ class ShellSession
140
137
  @stderr_buffer = []
141
138
  @stderr_bytes = 0
142
139
  @stderr_truncated = false
140
+ @max_output_bytes = Anima::Settings.max_output_bytes
143
141
  @stderr_thread = Thread.new do
142
+ max_bytes = @max_output_bytes
144
143
  File.open(@fifo_path, "r") do |fifo|
145
144
  while (line = fifo.gets)
146
145
  cleaned = line.chomp.delete("\r")
147
146
  @stderr_mutex.synchronize do
148
- if @stderr_bytes < MAX_OUTPUT_BYTES
147
+ if @stderr_bytes < max_bytes
149
148
  @stderr_buffer << cleaned
150
149
  @stderr_bytes += cleaned.bytesize
151
150
  else
@@ -172,8 +171,9 @@ class ShellSession
172
171
  def execute_in_pty(command)
173
172
  clear_stderr
174
173
  marker = "__ANIMA_#{SecureRandom.hex(8)}__"
174
+ timeout = Anima::Settings.command_timeout
175
175
 
176
- Timeout.timeout(COMMAND_TIMEOUT) do
176
+ Timeout.timeout(timeout) do
177
177
  # All on one line: run command, capture exit code, ensure newline
178
178
  # before marker so output without trailing newline doesn't merge.
179
179
  @pty_stdin.puts "#{command}; __anima_ec=$?; echo; echo '#{marker}' $__anima_ec"
@@ -190,7 +190,7 @@ class ShellSession
190
190
  end
191
191
  rescue Timeout::Error
192
192
  recover_from_timeout
193
- {error: "Command timed out after #{COMMAND_TIMEOUT} seconds"}
193
+ {error: "Command timed out after #{timeout} seconds"}
194
194
  rescue Errno::EIO
195
195
  @alive = false
196
196
  {error: "Shell session terminated unexpectedly"}
@@ -260,7 +260,7 @@ class ShellSession
260
260
  @stderr_buffer.clear
261
261
  @stderr_bytes = 0
262
262
  @stderr_truncated = false
263
- truncated ? result + "\n\n[Truncated: output exceeded #{MAX_OUTPUT_BYTES} bytes]" : result
263
+ truncated ? result + "\n\n[Truncated: output exceeded #{@max_output_bytes} bytes]" : result
264
264
  end
265
265
  end
266
266
 
@@ -274,12 +274,13 @@ class ShellSession
274
274
  end
275
275
 
276
276
  def truncate(output)
277
- return output if output.bytesize <= MAX_OUTPUT_BYTES
277
+ max_bytes = @max_output_bytes
278
+ return output if output.bytesize <= max_bytes
278
279
 
279
- output.byteslice(0, MAX_OUTPUT_BYTES)
280
+ output.byteslice(0, max_bytes)
280
281
  .force_encoding("UTF-8")
281
282
  .scrub +
282
- "\n\n[Truncated: output exceeded #{MAX_OUTPUT_BYTES} bytes]"
283
+ "\n\n[Truncated: output exceeded #{max_bytes} bytes]"
283
284
  end
284
285
 
285
286
  def shutdown
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Skills
6
+ class InvalidDefinitionError < StandardError; end
7
+
8
+ # A domain knowledge skill parsed from a Markdown definition file.
9
+ # YAML frontmatter holds metadata; the Markdown body is the knowledge
10
+ # content injected into the main agent's system prompt when active.
11
+ #
12
+ # Skills are passive knowledge — they describe WHAT you know, not
13
+ # WHAT to do. The analytical brain activates/deactivates them based
14
+ # on conversation context.
15
+ #
16
+ # @example Skill file format
17
+ # ---
18
+ # name: gh-issue
19
+ # description: "GitHub issue writing with WHAT/WHY/HOW framework."
20
+ # ---
21
+ #
22
+ # # GitHub Issue Writing
23
+ # Write issues with clear rationale...
24
+ class Definition
25
+ # @return [String] unique skill identifier used in activate_skill(name: "...")
26
+ attr_reader :name
27
+
28
+ # @return [String] description shown to the analytical brain for relevance matching
29
+ attr_reader :description
30
+
31
+ # @return [String] knowledge content (Markdown body) injected into system prompt
32
+ attr_reader :content
33
+
34
+ # @return [String] file path this definition was loaded from
35
+ attr_reader :source_path
36
+
37
+ def initialize(name:, description:, content:, source_path: "")
38
+ @name = name
39
+ @description = description
40
+ @content = content
41
+ @source_path = source_path
42
+ end
43
+
44
+ # Parses a Markdown file with YAML frontmatter into a Definition.
45
+ #
46
+ # @param path [String, Pathname] path to the .md file
47
+ # @return [Definition]
48
+ # @raise [InvalidDefinitionError] if required fields are missing or frontmatter is malformed
49
+ def self.from_file(path)
50
+ raw = File.read(path)
51
+ frontmatter, body = parse_frontmatter(raw)
52
+
53
+ validate_required_fields!(frontmatter, path)
54
+
55
+ new(
56
+ name: frontmatter["name"].to_s.strip,
57
+ description: frontmatter["description"].to_s.strip,
58
+ content: body.strip,
59
+ source_path: path.to_s
60
+ )
61
+ end
62
+
63
+ # @param raw [String] raw file content with YAML frontmatter
64
+ # @return [Array(Hash, String)] parsed frontmatter and body text
65
+ # @raise [InvalidDefinitionError] if frontmatter is missing or malformed
66
+ def self.parse_frontmatter(raw)
67
+ # Opening "---" must be followed by a newline (not just whitespace).
68
+ # Non-greedy (.*?\n) captures YAML lines up to the closing "---".
69
+ # Closing "---" may optionally be followed by a newline before the body.
70
+ # The /m flag lets (.*) in the body capture across newlines.
71
+ match = raw.match(/\A---\s*\n(.*?\n)---\s*\n?(.*)\z/m)
72
+ raise InvalidDefinitionError, "Missing YAML frontmatter" unless match
73
+
74
+ frontmatter = YAML.safe_load(match[1])
75
+ raise InvalidDefinitionError, "Frontmatter is not a valid YAML mapping" unless frontmatter.is_a?(Hash)
76
+
77
+ [frontmatter, match[2]]
78
+ end
79
+
80
+ NAME_FORMAT = /\A[a-z0-9][a-z0-9_-]*\z/
81
+
82
+ def self.validate_required_fields!(frontmatter, path)
83
+ %w[name description].each do |field|
84
+ value = frontmatter[field].to_s.strip
85
+ raise InvalidDefinitionError, "Missing required field '#{field}' in #{path}" if value.empty?
86
+ end
87
+
88
+ name = frontmatter["name"].to_s.strip
89
+ unless name.match?(NAME_FORMAT)
90
+ raise InvalidDefinitionError,
91
+ "Invalid skill name '#{name}' in #{path} — must be lowercase alphanumeric with hyphens/underscores"
92
+ end
93
+ end
94
+
95
+ private_class_method :parse_frontmatter, :validate_required_fields!
96
+ end
97
+ end
@@ -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 CHANGED
@@ -16,8 +16,6 @@ module Tools
16
16
  # "new_text" => "def greet\n 'hello'\nend")
17
17
  # # => "--- app.rb\n+++ app.rb\n@@ -1,3 +1,3 @@\n ..."
18
18
  class Edit < Base
19
- MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
20
-
21
19
  def self.tool_name = "edit"
22
20
 
23
21
  def self.description = "Replace exact text in a file. old_text must match exactly one location; " \
@@ -79,9 +77,10 @@ module Tools
79
77
  return {error: "Is a directory: #{path}"} if File.directory?(path)
80
78
  return {error: "Permission denied: #{path}"} unless File.readable?(path) && File.writable?(path)
81
79
  size = File.size(path)
82
- if size > MAX_FILE_SIZE
80
+ max_size = Anima::Settings.max_file_size
81
+ if size > max_size
83
82
  {error: "File is #{size} bytes (#{size / 1_048_576} MB). " \
84
- "Max editable size is #{MAX_FILE_SIZE / 1_048_576} MB. Use bash tool with sed instead."}
83
+ "Max editable size is #{max_size / 1_048_576} MB. Use bash tool with sed instead."}
85
84
  end
86
85
  end
87
86