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.
- checksums.yaml +4 -4
- data/.reek.yml +27 -1
- data/CHANGELOG.md +19 -0
- data/README.md +213 -43
- data/agents/codebase-analyzer.md +88 -0
- data/agents/codebase-pattern-finder.md +83 -0
- data/agents/documentation-researcher.md +59 -0
- data/agents/thoughts-analyzer.md +102 -0
- data/agents/web-search-researcher.md +71 -0
- data/anima-core.gemspec +3 -0
- data/app/channels/session_channel.rb +195 -45
- data/app/decorators/user_message_decorator.rb +16 -5
- data/app/jobs/agent_request_job.rb +55 -2
- data/app/jobs/analytical_brain_job.rb +33 -0
- data/app/jobs/count_event_tokens_job.rb +15 -4
- data/app/models/concerns/event/broadcasting.rb +81 -0
- data/app/models/event.rb +20 -1
- data/app/models/goal.rb +91 -0
- data/app/models/session.rb +366 -21
- data/config/application.rb +2 -0
- data/config/initializers/event_subscribers.rb +0 -1
- data/config/routes.rb +0 -6
- data/db/migrate/20260313010000_add_status_to_events.rb +8 -0
- data/db/migrate/20260313020000_add_processing_to_sessions.rb +7 -0
- data/db/migrate/20260314075248_add_subagent_support_to_sessions.rb +6 -0
- data/db/migrate/20260314112417_add_granted_tools_to_sessions.rb +5 -0
- data/db/migrate/20260314140000_add_name_to_sessions.rb +7 -0
- data/db/migrate/20260314150000_add_viewport_event_ids_to_sessions.rb +7 -0
- data/db/migrate/20260315100000_add_active_skills_to_sessions.rb +7 -0
- data/db/migrate/20260315140843_create_goals.rb +16 -0
- data/db/migrate/20260315144837_add_completed_at_to_goals.rb +5 -0
- data/db/migrate/20260315191105_add_active_workflow_to_sessions.rb +5 -0
- data/lib/agent_loop.rb +65 -6
- data/lib/agents/definition.rb +116 -0
- data/lib/agents/registry.rb +106 -0
- data/lib/analytical_brain/runner.rb +276 -0
- data/lib/analytical_brain/tools/activate_skill.rb +52 -0
- data/lib/analytical_brain/tools/deactivate_skill.rb +43 -0
- data/lib/analytical_brain/tools/deactivate_workflow.rb +34 -0
- data/lib/analytical_brain/tools/everything_is_ready.rb +28 -0
- data/lib/analytical_brain/tools/finish_goal.rb +62 -0
- data/lib/analytical_brain/tools/read_workflow.rb +58 -0
- data/lib/analytical_brain/tools/rename_session.rb +63 -0
- data/lib/analytical_brain/tools/set_goal.rb +60 -0
- data/lib/analytical_brain/tools/update_goal.rb +60 -0
- data/lib/analytical_brain.rb +23 -0
- data/lib/anima/cli/mcp/secrets.rb +76 -0
- data/lib/anima/cli/mcp.rb +197 -0
- data/lib/anima/cli.rb +5 -40
- data/lib/anima/installer.rb +168 -0
- data/lib/anima/settings.rb +226 -0
- data/lib/anima/version.rb +1 -1
- data/lib/anima.rb +9 -0
- data/lib/credential_store.rb +103 -0
- data/lib/environment_probe.rb +232 -0
- data/lib/events/subscribers/persister.rb +1 -0
- data/lib/events/user_message.rb +17 -0
- data/lib/llm/client.rb +29 -10
- data/lib/mcp/client_manager.rb +86 -0
- data/lib/mcp/config.rb +213 -0
- data/lib/mcp/health_check.rb +77 -0
- data/lib/mcp/secrets.rb +73 -0
- data/lib/mcp/stdio_transport.rb +206 -0
- data/lib/providers/anthropic.rb +11 -20
- data/lib/shell_session.rb +11 -10
- data/lib/skills/definition.rb +97 -0
- data/lib/skills/registry.rb +105 -0
- data/lib/tools/edit.rb +226 -0
- data/lib/tools/mcp_tool.rb +114 -0
- data/lib/tools/read.rb +151 -0
- data/lib/tools/registry.rb +14 -12
- data/lib/tools/request_feature.rb +121 -0
- data/lib/tools/return_result.rb +81 -0
- data/lib/tools/spawn_specialist.rb +109 -0
- data/lib/tools/spawn_subagent.rb +111 -0
- data/lib/tools/subagent_prompts.rb +12 -0
- data/lib/tools/web_get.rb +8 -9
- data/lib/tools/write.rb +86 -0
- data/lib/tui/app.rb +985 -26
- data/lib/tui/cable_client.rb +69 -31
- data/lib/tui/message_store.rb +103 -8
- data/lib/tui/screens/chat.rb +293 -45
- data/lib/workflows/definition.rb +97 -0
- data/lib/workflows/registry.rb +89 -0
- data/skills/activerecord/SKILL.md +255 -0
- data/skills/activerecord/examples/associations/association_extensions.rb +298 -0
- data/skills/activerecord/examples/associations/basic_associations.rb +118 -0
- data/skills/activerecord/examples/associations/counter_caches.rb +215 -0
- data/skills/activerecord/examples/associations/polymorphic_associations.rb +217 -0
- data/skills/activerecord/examples/associations/self_referential.rb +302 -0
- data/skills/activerecord/examples/associations/through_associations.rb +203 -0
- data/skills/activerecord/examples/basics/crud_operations.rb +209 -0
- data/skills/activerecord/examples/basics/dirty_tracking.rb +218 -0
- data/skills/activerecord/examples/basics/inheritance.rb +377 -0
- data/skills/activerecord/examples/basics/type_casting.rb +317 -0
- data/skills/activerecord/examples/callbacks/alternatives_to_callbacks.rb +447 -0
- data/skills/activerecord/examples/callbacks/conditional_callbacks.rb +353 -0
- data/skills/activerecord/examples/callbacks/lifecycle_callbacks.rb +280 -0
- data/skills/activerecord/examples/callbacks/transaction_callbacks.rb +340 -0
- data/skills/activerecord/examples/migrations/indexes_and_constraints.rb +337 -0
- data/skills/activerecord/examples/migrations/reversible_patterns.rb +403 -0
- data/skills/activerecord/examples/migrations/safe_patterns.rb +420 -0
- data/skills/activerecord/examples/migrations/schema_changes.rb +277 -0
- data/skills/activerecord/examples/querying/batch_processing.rb +226 -0
- data/skills/activerecord/examples/querying/eager_loading.rb +259 -0
- data/skills/activerecord/examples/querying/finder_methods.rb +170 -0
- data/skills/activerecord/examples/querying/optimization.rb +275 -0
- data/skills/activerecord/examples/querying/scopes.rb +260 -0
- data/skills/activerecord/examples/validations/built_in_validators.rb +277 -0
- data/skills/activerecord/examples/validations/conditional_validations.rb +288 -0
- data/skills/activerecord/examples/validations/custom_validators.rb +381 -0
- data/skills/activerecord/examples/validations/database_constraints.rb +432 -0
- data/skills/activerecord/examples/validations/validation_contexts.rb +367 -0
- data/skills/activerecord/references/associations.md +709 -0
- data/skills/activerecord/references/basics.md +622 -0
- data/skills/activerecord/references/callbacks.md +738 -0
- data/skills/activerecord/references/migrations.md +657 -0
- data/skills/activerecord/references/querying.md +655 -0
- data/skills/activerecord/references/validations.md +596 -0
- data/skills/dragonruby/SKILL.md +250 -0
- data/skills/dragonruby/examples/audio/audio_events.rb +55 -0
- data/skills/dragonruby/examples/audio/background_music.rb +29 -0
- data/skills/dragonruby/examples/audio/crossfade.rb +51 -0
- data/skills/dragonruby/examples/audio/music_controls.rb +51 -0
- data/skills/dragonruby/examples/audio/sound_effects.rb +30 -0
- data/skills/dragonruby/examples/core/coordinate_system.rb +27 -0
- data/skills/dragonruby/examples/core/hello_world.rb +24 -0
- data/skills/dragonruby/examples/core/labels.rb +22 -0
- data/skills/dragonruby/examples/core/sprites.rb +35 -0
- data/skills/dragonruby/examples/core/state_management.rb +29 -0
- data/skills/dragonruby/examples/distribution/background_pause.rb +42 -0
- data/skills/dragonruby/examples/distribution/build_workflow.sh +26 -0
- data/skills/dragonruby/examples/distribution/cvars_production.txt +16 -0
- data/skills/dragonruby/examples/distribution/game_metadata_hd.txt +23 -0
- data/skills/dragonruby/examples/distribution/game_metadata_minimal.txt +9 -0
- data/skills/dragonruby/examples/distribution/game_metadata_mobile.txt +31 -0
- data/skills/dragonruby/examples/distribution/platform_detection.rb +36 -0
- data/skills/dragonruby/examples/distribution/steam_metadata.txt +19 -0
- data/skills/dragonruby/examples/entities/collision_detection.rb +43 -0
- data/skills/dragonruby/examples/entities/entity_lifecycle.rb +68 -0
- data/skills/dragonruby/examples/entities/entity_storage.rb +38 -0
- data/skills/dragonruby/examples/entities/factory_methods.rb +45 -0
- data/skills/dragonruby/examples/entities/random_spawning.rb +50 -0
- data/skills/dragonruby/examples/game-logic/reset_patterns.rb +98 -0
- data/skills/dragonruby/examples/game-logic/save_load.rb +101 -0
- data/skills/dragonruby/examples/game-logic/scoring.rb +104 -0
- data/skills/dragonruby/examples/game-logic/state_transitions.rb +103 -0
- data/skills/dragonruby/examples/game-logic/timers.rb +87 -0
- data/skills/dragonruby/examples/input/action_triggers.rb +36 -0
- data/skills/dragonruby/examples/input/analog_movement.rb +28 -0
- data/skills/dragonruby/examples/input/controller_input.rb +28 -0
- data/skills/dragonruby/examples/input/directional_input.rb +24 -0
- data/skills/dragonruby/examples/input/keyboard_input.rb +28 -0
- data/skills/dragonruby/examples/input/mouse_click.rb +26 -0
- data/skills/dragonruby/examples/input/movement_with_bounds.rb +22 -0
- data/skills/dragonruby/examples/input/normalized_movement.rb +32 -0
- data/skills/dragonruby/examples/rendering/frame_animation.rb +32 -0
- data/skills/dragonruby/examples/rendering/labels.rb +32 -0
- data/skills/dragonruby/examples/rendering/layering.rb +51 -0
- data/skills/dragonruby/examples/rendering/solids.rb +61 -0
- data/skills/dragonruby/examples/rendering/sprites.rb +33 -0
- data/skills/dragonruby/examples/rendering/spritesheet_animation.rb +39 -0
- data/skills/dragonruby/examples/scenes/case_dispatch.rb +60 -0
- data/skills/dragonruby/examples/scenes/class_based.rb +150 -0
- data/skills/dragonruby/examples/scenes/pause_overlay.rb +100 -0
- data/skills/dragonruby/examples/scenes/safe_transitions.rb +68 -0
- data/skills/dragonruby/examples/scenes/scene_transitions.rb +98 -0
- data/skills/dragonruby/examples/scenes/send_dispatch.rb +88 -0
- data/skills/dragonruby/references/audio.md +396 -0
- data/skills/dragonruby/references/core.md +385 -0
- data/skills/dragonruby/references/distribution.md +434 -0
- data/skills/dragonruby/references/entities.md +516 -0
- data/skills/dragonruby/references/game-logic/persistence.md +386 -0
- data/skills/dragonruby/references/game-logic/state.md +389 -0
- data/skills/dragonruby/references/input.md +414 -0
- data/skills/dragonruby/references/rendering/animation.md +467 -0
- data/skills/dragonruby/references/rendering/primitives.md +403 -0
- data/skills/dragonruby/references/scenes.md +443 -0
- data/skills/draper-decorators/SKILL.md +344 -0
- data/skills/draper-decorators/examples/application_decorator.rb +61 -0
- data/skills/draper-decorators/examples/decorator_spec.rb +253 -0
- data/skills/draper-decorators/examples/model_decorator.rb +152 -0
- data/skills/draper-decorators/references/anti-patterns.md +640 -0
- data/skills/draper-decorators/references/patterns.md +507 -0
- data/skills/draper-decorators/references/testing.md +559 -0
- data/skills/gh-issue.md +182 -0
- data/skills/mcp-server/SKILL.md +177 -0
- data/skills/mcp-server/examples/dynamic_tools.rb +36 -0
- data/skills/mcp-server/examples/file_manager_tool.rb +85 -0
- data/skills/mcp-server/examples/http_client.rb +48 -0
- data/skills/mcp-server/examples/http_server.rb +97 -0
- data/skills/mcp-server/examples/rails_integration.rb +88 -0
- data/skills/mcp-server/examples/stdio_server.rb +108 -0
- data/skills/mcp-server/examples/streaming_client.rb +95 -0
- data/skills/mcp-server/references/gotchas.md +183 -0
- data/skills/mcp-server/references/prompts.md +98 -0
- data/skills/mcp-server/references/resources.md +53 -0
- data/skills/mcp-server/references/server.md +140 -0
- data/skills/mcp-server/references/tools.md +146 -0
- data/skills/mcp-server/references/transport.md +104 -0
- data/skills/ratatui-ruby/SKILL.md +315 -0
- data/skills/ratatui-ruby/references/core-concepts.md +340 -0
- data/skills/ratatui-ruby/references/events.md +387 -0
- data/skills/ratatui-ruby/references/frameworks.md +522 -0
- data/skills/ratatui-ruby/references/layout.md +423 -0
- data/skills/ratatui-ruby/references/styling.md +268 -0
- data/skills/ratatui-ruby/references/testing.md +433 -0
- data/skills/ratatui-ruby/references/widgets.md +532 -0
- data/skills/rspec/SKILL.md +340 -0
- data/skills/rspec/examples/core/basic_structure.rb +69 -0
- data/skills/rspec/examples/core/configuration.rb +126 -0
- data/skills/rspec/examples/core/hooks.rb +126 -0
- data/skills/rspec/examples/core/memoized_helpers.rb +139 -0
- data/skills/rspec/examples/core/metadata_filtering.rb +144 -0
- data/skills/rspec/examples/core/shared_examples.rb +145 -0
- data/skills/rspec/examples/factory_bot/associations.rb +314 -0
- data/skills/rspec/examples/factory_bot/build_strategies.rb +272 -0
- data/skills/rspec/examples/factory_bot/callbacks.rb +320 -0
- data/skills/rspec/examples/factory_bot/custom_construction.rb +328 -0
- data/skills/rspec/examples/factory_bot/factory_definition.rb +191 -0
- data/skills/rspec/examples/factory_bot/inheritance.rb +314 -0
- data/skills/rspec/examples/factory_bot/traits.rb +293 -0
- data/skills/rspec/examples/factory_bot/transients.rb +229 -0
- data/skills/rspec/examples/matchers/change.rb +115 -0
- data/skills/rspec/examples/matchers/collections.rb +154 -0
- data/skills/rspec/examples/matchers/comparisons.rb +79 -0
- data/skills/rspec/examples/matchers/composing.rb +155 -0
- data/skills/rspec/examples/matchers/custom_matchers.rb +197 -0
- data/skills/rspec/examples/matchers/equality.rb +58 -0
- data/skills/rspec/examples/matchers/errors.rb +136 -0
- data/skills/rspec/examples/matchers/output.rb +103 -0
- data/skills/rspec/examples/matchers/predicates.rb +87 -0
- data/skills/rspec/examples/matchers/truthiness.rb +101 -0
- data/skills/rspec/examples/matchers/types.rb +82 -0
- data/skills/rspec/examples/matchers/yield.rb +147 -0
- data/skills/rspec/examples/mocks/any_instance.rb +172 -0
- data/skills/rspec/examples/mocks/argument_matchers.rb +206 -0
- data/skills/rspec/examples/mocks/constants.rb +177 -0
- data/skills/rspec/examples/mocks/doubles.rb +139 -0
- data/skills/rspec/examples/mocks/expectations.rb +137 -0
- data/skills/rspec/examples/mocks/message_chains.rb +173 -0
- data/skills/rspec/examples/mocks/ordering.rb +144 -0
- data/skills/rspec/examples/mocks/receive_counts.rb +181 -0
- data/skills/rspec/examples/mocks/responses.rb +223 -0
- data/skills/rspec/examples/mocks/spies.rb +149 -0
- data/skills/rspec/examples/mocks/stubbing.rb +133 -0
- data/skills/rspec/examples/rails/channels.rb +250 -0
- data/skills/rspec/examples/rails/controller_specs.rb +302 -0
- data/skills/rspec/examples/rails/helper_specs.rb +245 -0
- data/skills/rspec/examples/rails/job_specs.rb +256 -0
- data/skills/rspec/examples/rails/mailer_specs.rb +228 -0
- data/skills/rspec/examples/rails/matchers.rb +374 -0
- data/skills/rspec/examples/rails/model_specs.rb +193 -0
- data/skills/rspec/examples/rails/request_specs.rb +275 -0
- data/skills/rspec/examples/rails/routing_specs.rb +276 -0
- data/skills/rspec/examples/rails/system_specs.rb +294 -0
- data/skills/rspec/examples/rails/transactions.rb +254 -0
- data/skills/rspec/examples/rails/view_specs.rb +252 -0
- data/skills/rspec/references/core.md +816 -0
- data/skills/rspec/references/factory_bot.md +641 -0
- data/skills/rspec/references/matchers.md +516 -0
- data/skills/rspec/references/mocks.md +381 -0
- data/skills/rspec/references/rails.md +528 -0
- data/templates/soul.md +40 -0
- data/workflows/commit.md +45 -0
- data/workflows/create_handoff.md +98 -0
- data/workflows/create_note.md +82 -0
- data/workflows/create_plan.md +457 -0
- data/workflows/decompose_ticket.md +109 -0
- data/workflows/feature.md +91 -0
- data/workflows/implement_plan.md +87 -0
- data/workflows/iterate_plan.md +247 -0
- data/workflows/research_codebase.md +210 -0
- data/workflows/resume_handoff.md +217 -0
- data/workflows/review_pr.md +320 -0
- data/workflows/thoughts_init.md +71 -0
- data/workflows/validate_plan.md +166 -0
- metadata +290 -3
- data/app/controllers/api/sessions_controller.rb +0 -25
- 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
|
data/lib/tools/registry.rb
CHANGED
|
@@ -4,16 +4,17 @@ module Tools
|
|
|
4
4
|
class UnknownToolError < StandardError; end
|
|
5
5
|
|
|
6
6
|
# Manages tool registration, schema export, and dispatch.
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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
|
|
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
|
|
26
|
-
# @param
|
|
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(
|
|
29
|
-
@tools[
|
|
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
|
-
#
|
|
38
|
-
#
|
|
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
|
-
|
|
46
|
-
|
|
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
|