rubino-agent 0.3.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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +115 -0
- data/.rubocop_todo.yml +955 -0
- data/.ruby-version +1 -0
- data/AGENTS.md +97 -0
- data/CHANGELOG.md +344 -0
- data/CONTRIBUTING.md +69 -0
- data/LICENSE +21 -0
- data/README.md +200 -0
- data/Rakefile +8 -0
- data/docs/agents.md +190 -0
- data/docs/api/v1.md +414 -0
- data/docs/architecture.md +177 -0
- data/docs/commands.md +375 -0
- data/docs/configuration.md +590 -0
- data/docs/getting-started.md +143 -0
- data/docs/jobs.md +332 -0
- data/docs/mcp.md +128 -0
- data/docs/memory.md +98 -0
- data/docs/models-and-keys.md +173 -0
- data/docs/oauth-providers.md +145 -0
- data/docs/plugins.md +195 -0
- data/docs/security.md +145 -0
- data/docs/skills.md +322 -0
- data/docs/tools.md +395 -0
- data/docs/troubleshooting.md +73 -0
- data/exe/rubino +9 -0
- data/install.sh +275 -0
- data/lib/rubino/active_skill.rb +50 -0
- data/lib/rubino/agent/agent_registry.rb +120 -0
- data/lib/rubino/agent/backoff_policy.rb +116 -0
- data/lib/rubino/agent/definition.rb +128 -0
- data/lib/rubino/agent/degenerate_recovery.rb +271 -0
- data/lib/rubino/agent/fallback_chain.rb +194 -0
- data/lib/rubino/agent/iteration_budget.rb +50 -0
- data/lib/rubino/agent/loop.rb +617 -0
- data/lib/rubino/agent/model_call_runner.rb +383 -0
- data/lib/rubino/agent/prompts/build.txt +69 -0
- data/lib/rubino/agent/prompts/compaction.txt +20 -0
- data/lib/rubino/agent/prompts/explore.txt +19 -0
- data/lib/rubino/agent/prompts/general.txt +20 -0
- data/lib/rubino/agent/prompts/plan.txt +31 -0
- data/lib/rubino/agent/response_validator.rb +70 -0
- data/lib/rubino/agent/router.rb +65 -0
- data/lib/rubino/agent/runner.rb +195 -0
- data/lib/rubino/agent/tool_executor.rb +402 -0
- data/lib/rubino/agent/truncation_continuation.rb +137 -0
- data/lib/rubino/api/middleware/auth.rb +43 -0
- data/lib/rubino/api/middleware/error_handler.rb +65 -0
- data/lib/rubino/api/middleware/json_parser.rb +100 -0
- data/lib/rubino/api/middleware/observability.rb +59 -0
- data/lib/rubino/api/middleware/rate_limit.rb +136 -0
- data/lib/rubino/api/operations/approvals/decide_operation.rb +49 -0
- data/lib/rubino/api/operations/clarifications/decide_operation.rb +44 -0
- data/lib/rubino/api/operations/cron_jobs/create_operation.rb +46 -0
- data/lib/rubino/api/operations/cron_jobs/delete_operation.rb +36 -0
- data/lib/rubino/api/operations/cron_jobs/list_operation.rb +55 -0
- data/lib/rubino/api/operations/cron_jobs/pause_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/resume_operation.rb +34 -0
- data/lib/rubino/api/operations/cron_jobs/schedule_validation.rb +30 -0
- data/lib/rubino/api/operations/cron_jobs/show_operation.rb +32 -0
- data/lib/rubino/api/operations/cron_jobs/trigger_operation.rb +38 -0
- data/lib/rubino/api/operations/cron_jobs/update_operation.rb +42 -0
- data/lib/rubino/api/operations/files/read_operation.rb +40 -0
- data/lib/rubino/api/operations/files/upload_operation.rb +175 -0
- data/lib/rubino/api/operations/health_operation.rb +46 -0
- data/lib/rubino/api/operations/memory/delete_operation.rb +32 -0
- data/lib/rubino/api/operations/memory/index_operation.rb +80 -0
- data/lib/rubino/api/operations/memory/stats_operation.rb +28 -0
- data/lib/rubino/api/operations/metrics_operation.rb +18 -0
- data/lib/rubino/api/operations/mode/show_operation.rb +29 -0
- data/lib/rubino/api/operations/mode/update_operation.rb +42 -0
- data/lib/rubino/api/operations/models/list_operation.rb +45 -0
- data/lib/rubino/api/operations/oauth/connections/disconnect_operation.rb +77 -0
- data/lib/rubino/api/operations/oauth/connections/list_operation.rb +36 -0
- data/lib/rubino/api/operations/oauth/providers/callback_operation.rb +82 -0
- data/lib/rubino/api/operations/oauth/providers/connect_operation.rb +44 -0
- data/lib/rubino/api/operations/oauth/providers/list_operation.rb +35 -0
- data/lib/rubino/api/operations/oauth/serializer.rb +21 -0
- data/lib/rubino/api/operations/runs/create_operation.rb +77 -0
- data/lib/rubino/api/operations/runs/events_operation.rb +195 -0
- data/lib/rubino/api/operations/runs/stop_operation.rb +34 -0
- data/lib/rubino/api/operations/sessions/create_operation.rb +46 -0
- data/lib/rubino/api/operations/sessions/delete_operation.rb +33 -0
- data/lib/rubino/api/operations/sessions/index_operation.rb +82 -0
- data/lib/rubino/api/operations/sessions/retry_operation.rb +45 -0
- data/lib/rubino/api/operations/sessions/show_operation.rb +59 -0
- data/lib/rubino/api/operations/sessions/undo_operation.rb +38 -0
- data/lib/rubino/api/operations/skills/list_operation.rb +34 -0
- data/lib/rubino/api/operations/skills/toggle_operation.rb +40 -0
- data/lib/rubino/api/operations/tasks/index_operation.rb +30 -0
- data/lib/rubino/api/operations/tasks/serializer.rb +60 -0
- data/lib/rubino/api/operations/tasks/show_operation.rb +33 -0
- data/lib/rubino/api/operations/tasks/stop_operation.rb +47 -0
- data/lib/rubino/api/request.rb +54 -0
- data/lib/rubino/api/responses.rb +64 -0
- data/lib/rubino/api/router.rb +72 -0
- data/lib/rubino/api/schemas.rb +103 -0
- data/lib/rubino/api/server.rb +102 -0
- data/lib/rubino/api/tls.rb +108 -0
- data/lib/rubino/attachments/classification.rb +16 -0
- data/lib/rubino/attachments/classify.rb +171 -0
- data/lib/rubino/attachments/defang.rb +47 -0
- data/lib/rubino/attachments/policy.rb +36 -0
- data/lib/rubino/attachments/preamble.rb +120 -0
- data/lib/rubino/boot/encryption_key.rb +32 -0
- data/lib/rubino/cli/chat/bang_shell.rb +257 -0
- data/lib/rubino/cli/chat/completion_builder.rb +290 -0
- data/lib/rubino/cli/chat/idle_card_host.rb +69 -0
- data/lib/rubino/cli/chat/image_inbox.rb +168 -0
- data/lib/rubino/cli/chat/session_resolver.rb +176 -0
- data/lib/rubino/cli/chat_command.rb +1674 -0
- data/lib/rubino/cli/commands.rb +250 -0
- data/lib/rubino/cli/config_command.rb +96 -0
- data/lib/rubino/cli/doctor_command.rb +251 -0
- data/lib/rubino/cli/jobs_command.rb +60 -0
- data/lib/rubino/cli/memory_command.rb +135 -0
- data/lib/rubino/cli/onboarding_wizard.rb +207 -0
- data/lib/rubino/cli/server_command.rb +139 -0
- data/lib/rubino/cli/session_command.rb +125 -0
- data/lib/rubino/cli/setup_command.rb +107 -0
- data/lib/rubino/cli/skills_command.rb +85 -0
- data/lib/rubino/cli/tools_command.rb +81 -0
- data/lib/rubino/cli/trust_gate.rb +71 -0
- data/lib/rubino/commands/built_ins.rb +46 -0
- data/lib/rubino/commands/command.rb +116 -0
- data/lib/rubino/commands/executor.rb +550 -0
- data/lib/rubino/commands/handlers/agents.rb +510 -0
- data/lib/rubino/commands/handlers/config.rb +88 -0
- data/lib/rubino/commands/handlers/help.rb +148 -0
- data/lib/rubino/commands/handlers/jobs.rb +71 -0
- data/lib/rubino/commands/handlers/mcp.rb +229 -0
- data/lib/rubino/commands/handlers/memory.rb +200 -0
- data/lib/rubino/commands/handlers/sessions.rb +207 -0
- data/lib/rubino/commands/handlers/skills.rb +195 -0
- data/lib/rubino/commands/handlers/status.rb +211 -0
- data/lib/rubino/commands/loader.rb +90 -0
- data/lib/rubino/config/configuration.rb +455 -0
- data/lib/rubino/config/defaults.rb +569 -0
- data/lib/rubino/config/loader.rb +115 -0
- data/lib/rubino/config/reasoning_prefs.rb +67 -0
- data/lib/rubino/config/writer.rb +72 -0
- data/lib/rubino/context/compressor.rb +149 -0
- data/lib/rubino/context/environment_inspector.rb +176 -0
- data/lib/rubino/context/file_discovery.rb +45 -0
- data/lib/rubino/context/message_boundary.rb +39 -0
- data/lib/rubino/context/prompt_assembler.rb +382 -0
- data/lib/rubino/context/summary_builder.rb +159 -0
- data/lib/rubino/context/token_budget.rb +68 -0
- data/lib/rubino/context/tool_pair_sanitizer.rb +70 -0
- data/lib/rubino/database/connection.rb +77 -0
- data/lib/rubino/database/migrations/001_create_initial_schema.rb +156 -0
- data/lib/rubino/database/migrations/002_create_runs.rb +45 -0
- data/lib/rubino/database/migrations/003_create_skill_states.rb +15 -0
- data/lib/rubino/database/migrations/004_create_cron_jobs.rb +36 -0
- data/lib/rubino/database/migrations/005_create_oauth_connections.rb +27 -0
- data/lib/rubino/database/migrations/006_create_webhook_deliveries.rb +34 -0
- data/lib/rubino/database/migrations/007_create_messages_fts.rb +59 -0
- data/lib/rubino/database/migrations/008_create_memory_facts.rb +75 -0
- data/lib/rubino/database/migrations/009_create_memory_graph.rb +55 -0
- data/lib/rubino/database/migrations/010_add_owner_pid_to_sessions.rb +20 -0
- data/lib/rubino/database/migrator.rb +48 -0
- data/lib/rubino/documents/converters/csv.rb +79 -0
- data/lib/rubino/documents/converters/docx.rb +129 -0
- data/lib/rubino/documents/converters/html.rb +28 -0
- data/lib/rubino/documents/converters/json.rb +35 -0
- data/lib/rubino/documents/converters/pdf.rb +59 -0
- data/lib/rubino/documents/converters/plain.rb +68 -0
- data/lib/rubino/documents/converters/pptx.rb +64 -0
- data/lib/rubino/documents/converters/xlsx.rb +62 -0
- data/lib/rubino/documents/converters/xml.rb +45 -0
- data/lib/rubino/documents/html.rb +71 -0
- data/lib/rubino/documents/registry.rb +68 -0
- data/lib/rubino/documents/table.rb +63 -0
- data/lib/rubino/documents.rb +50 -0
- data/lib/rubino/errors.rb +119 -0
- data/lib/rubino/files/workspace.rb +93 -0
- data/lib/rubino/interaction/cancel_token.rb +43 -0
- data/lib/rubino/interaction/clipboard_image.rb +84 -0
- data/lib/rubino/interaction/event_bus.rb +48 -0
- data/lib/rubino/interaction/events.rb +101 -0
- data/lib/rubino/interaction/image_input.rb +127 -0
- data/lib/rubino/interaction/input_queue.rb +117 -0
- data/lib/rubino/interaction/lifecycle.rb +299 -0
- data/lib/rubino/interaction/probe.rb +65 -0
- data/lib/rubino/interaction/state.rb +56 -0
- data/lib/rubino/jobs/cron_job_repository.rb +75 -0
- data/lib/rubino/jobs/handlers/cleanup_sessions_job.rb +32 -0
- data/lib/rubino/jobs/handlers/compact_session_job.rb +21 -0
- data/lib/rubino/jobs/handlers/distill_skill_job.rb +186 -0
- data/lib/rubino/jobs/handlers/extract_memory_job.rb +37 -0
- data/lib/rubino/jobs/handlers/summarize_session_job.rb +21 -0
- data/lib/rubino/jobs/queue.rb +184 -0
- data/lib/rubino/jobs/registry.rb +45 -0
- data/lib/rubino/jobs/runner.rb +79 -0
- data/lib/rubino/jobs/scheduler.rb +138 -0
- data/lib/rubino/jobs/webhook_delivery.rb +225 -0
- data/lib/rubino/jobs/worker.rb +59 -0
- data/lib/rubino/llm/adapter_factory.rb +47 -0
- data/lib/rubino/llm/adapter_response.rb +65 -0
- data/lib/rubino/llm/auxiliary_client.rb +61 -0
- data/lib/rubino/llm/bedrock_bearer_client.rb +235 -0
- data/lib/rubino/llm/content_builder.rb +55 -0
- data/lib/rubino/llm/credential_check.rb +93 -0
- data/lib/rubino/llm/error_classifier.rb +364 -0
- data/lib/rubino/llm/fake_provider.rb +292 -0
- data/lib/rubino/llm/inline_think_filter.rb +58 -0
- data/lib/rubino/llm/model_catalog.rb +29 -0
- data/lib/rubino/llm/provider_resolver.rb +48 -0
- data/lib/rubino/llm/reasoning_manager.rb +100 -0
- data/lib/rubino/llm/request.rb +56 -0
- data/lib/rubino/llm/ruby_llm_adapter.rb +794 -0
- data/lib/rubino/llm/scenario_loader.rb +68 -0
- data/lib/rubino/llm/scenario_selector.rb +80 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron-failure.yml +29 -0
- data/lib/rubino/llm/scenarios/agent-creates-cron.yml +36 -0
- data/lib/rubino/llm/scenarios/analysis.yml +501 -0
- data/lib/rubino/llm/scenarios/complex-analysis.yml +598 -0
- data/lib/rubino/llm/scenarios/failure.yml +65 -0
- data/lib/rubino/llm/scenarios/happy-path.yml +24 -0
- data/lib/rubino/llm/scenarios/provider-quota-completed.yml +14 -0
- data/lib/rubino/llm/scenarios/wide-table.yml +121 -0
- data/lib/rubino/llm/scenarios/with-approvals.yml +50 -0
- data/lib/rubino/llm/scenarios/with-artifacts.yml +98 -0
- data/lib/rubino/llm/scenarios/with-clarify.yml +32 -0
- data/lib/rubino/llm/scenarios/with-reasoning.yml +175 -0
- data/lib/rubino/llm/scenarios/with-uploads.yml +104 -0
- data/lib/rubino/llm/thinking_support.rb +84 -0
- data/lib/rubino/llm/tool_bridge.rb +89 -0
- data/lib/rubino/logger.rb +99 -0
- data/lib/rubino/mcp/manager.rb +180 -0
- data/lib/rubino/mcp/mcp_tool_wrapper.rb +69 -0
- data/lib/rubino/mcp.rb +57 -0
- data/lib/rubino/memory/backend.rb +104 -0
- data/lib/rubino/memory/backends/default.rb +101 -0
- data/lib/rubino/memory/backends/sqlite.rb +653 -0
- data/lib/rubino/memory/backends.rb +53 -0
- data/lib/rubino/memory/deduplicator.rb +74 -0
- data/lib/rubino/memory/extractor.rb +85 -0
- data/lib/rubino/memory/flusher.rb +31 -0
- data/lib/rubino/memory/retriever.rb +50 -0
- data/lib/rubino/memory/sqlite_extraction_prompt.rb +70 -0
- data/lib/rubino/memory/sqlite_graph.rb +154 -0
- data/lib/rubino/memory/store.rb +228 -0
- data/lib/rubino/memory/threat_scanner.rb +68 -0
- data/lib/rubino/metrics.rb +175 -0
- data/lib/rubino/modes.rb +93 -0
- data/lib/rubino/oauth/connection_repository.rb +95 -0
- data/lib/rubino/oauth/provider/github.rb +75 -0
- data/lib/rubino/oauth/provider/google.rb +59 -0
- data/lib/rubino/oauth/provider.rb +149 -0
- data/lib/rubino/oauth/registry.rb +86 -0
- data/lib/rubino/oauth/token_encryptor.rb +87 -0
- data/lib/rubino/plugins/registry.rb +75 -0
- data/lib/rubino/plugins.rb +86 -0
- data/lib/rubino/run/approval_gate.rb +243 -0
- data/lib/rubino/run/attachment_downloader.rb +166 -0
- data/lib/rubino/run/event_store.rb +74 -0
- data/lib/rubino/run/executor.rb +383 -0
- data/lib/rubino/run/gate_registry.rb +39 -0
- data/lib/rubino/run/recorder.rb +69 -0
- data/lib/rubino/run/repository.rb +118 -0
- data/lib/rubino/run/session_approval_cache.rb +118 -0
- data/lib/rubino/security/allowlist_persister.rb +55 -0
- data/lib/rubino/security/approval_policy.rb +227 -0
- data/lib/rubino/security/command_allowlist.rb +24 -0
- data/lib/rubino/security/dangerous_patterns.rb +118 -0
- data/lib/rubino/security/deny_persister.rb +73 -0
- data/lib/rubino/security/doom_loop_detector.rb +43 -0
- data/lib/rubino/security/hardline_guard.rb +105 -0
- data/lib/rubino/security/pattern_matcher.rb +62 -0
- data/lib/rubino/security/prefix_deriver.rb +124 -0
- data/lib/rubino/security/readonly_commands.rb +211 -0
- data/lib/rubino/session/exporter.rb +101 -0
- data/lib/rubino/session/message.rb +77 -0
- data/lib/rubino/session/repository.rb +295 -0
- data/lib/rubino/session/store.rb +198 -0
- data/lib/rubino/session/summary_store.rb +65 -0
- data/lib/rubino/skills/prompt_index.rb +85 -0
- data/lib/rubino/skills/registry.rb +208 -0
- data/lib/rubino/skills/skill.rb +176 -0
- data/lib/rubino/skills/skill_tool.rb +215 -0
- data/lib/rubino/skills/state_repository.rb +37 -0
- data/lib/rubino/skills/toggle.rb +26 -0
- data/lib/rubino/tools/answer_child_tool.rb +83 -0
- data/lib/rubino/tools/ask_parent_tool.rb +232 -0
- data/lib/rubino/tools/attach_file_tool.rb +120 -0
- data/lib/rubino/tools/background_tasks.rb +520 -0
- data/lib/rubino/tools/base.rb +222 -0
- data/lib/rubino/tools/custom_tool_loader.rb +119 -0
- data/lib/rubino/tools/edit_tool.rb +122 -0
- data/lib/rubino/tools/git_tool.rb +71 -0
- data/lib/rubino/tools/github_tool.rb +233 -0
- data/lib/rubino/tools/glob_tool.rb +69 -0
- data/lib/rubino/tools/grep_tool.rb +206 -0
- data/lib/rubino/tools/memory_tool.rb +184 -0
- data/lib/rubino/tools/multi_edit_tool.rb +110 -0
- data/lib/rubino/tools/patch_tool.rb +260 -0
- data/lib/rubino/tools/probe_tool.rb +175 -0
- data/lib/rubino/tools/question_tool.rb +128 -0
- data/lib/rubino/tools/read_attachment_tool.rb +180 -0
- data/lib/rubino/tools/read_tool.rb +212 -0
- data/lib/rubino/tools/read_tracker.rb +98 -0
- data/lib/rubino/tools/registry.rb +166 -0
- data/lib/rubino/tools/result.rb +113 -0
- data/lib/rubino/tools/ruby_tool.rb +0 -0
- data/lib/rubino/tools/session_search_tool.rb +103 -0
- data/lib/rubino/tools/shell_input_tool.rb +96 -0
- data/lib/rubino/tools/shell_kill_tool.rb +76 -0
- data/lib/rubino/tools/shell_output_tool.rb +72 -0
- data/lib/rubino/tools/shell_registry.rb +158 -0
- data/lib/rubino/tools/shell_tail_tool.rb +118 -0
- data/lib/rubino/tools/shell_tool.rb +330 -0
- data/lib/rubino/tools/steer_tool.rb +118 -0
- data/lib/rubino/tools/subagent_probe.rb +89 -0
- data/lib/rubino/tools/summarize_file_tool.rb +182 -0
- data/lib/rubino/tools/task_result_tool.rb +90 -0
- data/lib/rubino/tools/task_stop_tool.rb +80 -0
- data/lib/rubino/tools/task_tool.rb +622 -0
- data/lib/rubino/tools/test_tool.rb +454 -0
- data/lib/rubino/tools/todo_tool.rb +93 -0
- data/lib/rubino/tools/tool_call_repository.rb +33 -0
- data/lib/rubino/tools/vision_tool.rb +85 -0
- data/lib/rubino/tools/webfetch_tool.rb +153 -0
- data/lib/rubino/tools/websearch_tool.rb +179 -0
- data/lib/rubino/tools/write_tool.rb +61 -0
- data/lib/rubino/trust.rb +88 -0
- data/lib/rubino/ui/api.rb +296 -0
- data/lib/rubino/ui/base.rb +252 -0
- data/lib/rubino/ui/bottom_composer.rb +1599 -0
- data/lib/rubino/ui/cli.rb +1987 -0
- data/lib/rubino/ui/completion_menu.rb +321 -0
- data/lib/rubino/ui/completion_source.rb +284 -0
- data/lib/rubino/ui/escape_reader.rb +169 -0
- data/lib/rubino/ui/indented_io.rb +88 -0
- data/lib/rubino/ui/input_history.rb +108 -0
- data/lib/rubino/ui/live_region.rb +183 -0
- data/lib/rubino/ui/markdown_renderer.rb +506 -0
- data/lib/rubino/ui/notifier.rb +163 -0
- data/lib/rubino/ui/null.rb +195 -0
- data/lib/rubino/ui/paste_store.rb +176 -0
- data/lib/rubino/ui/printer_base.rb +79 -0
- data/lib/rubino/ui/probe_wait_indicator.rb +75 -0
- data/lib/rubino/ui/queued_indicators.rb +66 -0
- data/lib/rubino/ui/status_bar.rb +100 -0
- data/lib/rubino/ui/stdout_proxy.rb +161 -0
- data/lib/rubino/ui/streaming_markdown.rb +186 -0
- data/lib/rubino/ui/subagent_cards.rb +134 -0
- data/lib/rubino/ui/subagent_view.rb +255 -0
- data/lib/rubino/ui.rb +21 -0
- data/lib/rubino/update_check.rb +187 -0
- data/lib/rubino/util/duration.rb +23 -0
- data/lib/rubino/util/hyperlink.rb +105 -0
- data/lib/rubino/util/output.rb +145 -0
- data/lib/rubino/util/secrets_mask.rb +83 -0
- data/lib/rubino/version.rb +5 -0
- data/lib/rubino/workspace.rb +85 -0
- data/lib/rubino-agent.rb +5 -0
- data/lib/rubino.rb +318 -0
- data/mise.toml +2 -0
- data/rubino-agent.gemspec +103 -0
- data/skills/ruby-expert/SKILL.md +67 -0
- data/skills/ruby-expert/references/concurrency.md +357 -0
- data/skills/ruby-expert/references/datetime-and-encoding.md +363 -0
- data/skills/ruby-expert/references/errors-and-types.md +460 -0
- data/skills/ruby-expert/references/gem-authoring.md +459 -0
- data/skills/ruby-expert/references/language-idioms.md +465 -0
- data/skills/ruby-expert/references/metaprogramming.md +339 -0
- data/skills/ruby-expert/references/oo-design.md +553 -0
- data/skills/ruby-expert/references/performance.md +383 -0
- data/skills/ruby-expert/references/rails.md +424 -0
- data/skills/ruby-expert/references/security.md +404 -0
- data/skills/ruby-expert/references/testing.md +473 -0
- data/skills/ruby-expert/references/tooling.md +466 -0
- metadata +856 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Rubino
|
|
8
|
+
module Tools
|
|
9
|
+
# Tool for GitHub/GitLab operations: PRs, issues, reviews.
|
|
10
|
+
# Uses GitHub CLI (gh) if available, otherwise uses the API directly.
|
|
11
|
+
class GitHubTool < Base
|
|
12
|
+
def name
|
|
13
|
+
"github"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def description
|
|
17
|
+
"Interact with GitHub: create/list PRs, issues, reviews, check status. " \
|
|
18
|
+
"Requires GITHUB_TOKEN or gh CLI authenticated."
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def input_schema
|
|
22
|
+
{
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
action: {
|
|
26
|
+
type: "string",
|
|
27
|
+
enum: %w[pr_create pr_list pr_view issue_create issue_list issue_view
|
|
28
|
+
pr_checks pr_diff repo_view release_list],
|
|
29
|
+
description: "The GitHub action to perform"
|
|
30
|
+
},
|
|
31
|
+
title: {
|
|
32
|
+
type: "string",
|
|
33
|
+
description: "Title (for pr_create, issue_create)"
|
|
34
|
+
},
|
|
35
|
+
body: {
|
|
36
|
+
type: "string",
|
|
37
|
+
description: "Body/description (for pr_create, issue_create)"
|
|
38
|
+
},
|
|
39
|
+
number: {
|
|
40
|
+
type: "integer",
|
|
41
|
+
description: "PR or issue number (for view/checks/diff)"
|
|
42
|
+
},
|
|
43
|
+
repo: {
|
|
44
|
+
type: "string",
|
|
45
|
+
description: "Repository in owner/name format (optional, auto-detects from git remote)"
|
|
46
|
+
},
|
|
47
|
+
base: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "Base branch for PR (default: main)"
|
|
50
|
+
},
|
|
51
|
+
labels: {
|
|
52
|
+
type: "string",
|
|
53
|
+
description: "Comma-separated labels"
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
required: %w[action]
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def risk_level
|
|
61
|
+
:medium
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def call(arguments)
|
|
65
|
+
action = arguments["action"] || arguments[:action]
|
|
66
|
+
|
|
67
|
+
if gh_available?
|
|
68
|
+
execute_gh(action, arguments)
|
|
69
|
+
else
|
|
70
|
+
execute_api(action, arguments)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def gh_available?
|
|
77
|
+
# Memoized — avoid spawning a subprocess on every call()
|
|
78
|
+
return @gh_available unless @gh_available.nil?
|
|
79
|
+
|
|
80
|
+
@gh_available = system("which gh > /dev/null 2>&1")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def execute_gh(action, args)
|
|
84
|
+
case action
|
|
85
|
+
when "pr_create"
|
|
86
|
+
title = args["title"] || args[:title] || "New PR"
|
|
87
|
+
body = args["body"] || args[:body] || ""
|
|
88
|
+
base = args["base"] || args[:base] || "main"
|
|
89
|
+
cmd = "gh pr create --title '#{escape(title)}' --body '#{escape(body)}' --base '#{base}'"
|
|
90
|
+
run_gh(cmd)
|
|
91
|
+
when "pr_list"
|
|
92
|
+
run_gh("gh pr list --limit 20")
|
|
93
|
+
when "pr_view"
|
|
94
|
+
number = args["number"] || args[:number]
|
|
95
|
+
run_gh("gh pr view #{number}")
|
|
96
|
+
when "pr_checks"
|
|
97
|
+
number = args["number"] || args[:number]
|
|
98
|
+
run_gh("gh pr checks #{number}")
|
|
99
|
+
when "pr_diff"
|
|
100
|
+
number = args["number"] || args[:number]
|
|
101
|
+
run_gh("gh pr diff #{number}")
|
|
102
|
+
when "issue_create"
|
|
103
|
+
title = args["title"] || args[:title] || "New Issue"
|
|
104
|
+
body = args["body"] || args[:body] || ""
|
|
105
|
+
labels = args["labels"] || args[:labels]
|
|
106
|
+
cmd = "gh issue create --title '#{escape(title)}' --body '#{escape(body)}'"
|
|
107
|
+
cmd += " --label '#{labels}'" if labels
|
|
108
|
+
run_gh(cmd)
|
|
109
|
+
when "issue_list"
|
|
110
|
+
run_gh("gh issue list --limit 20")
|
|
111
|
+
when "issue_view"
|
|
112
|
+
number = args["number"] || args[:number]
|
|
113
|
+
run_gh("gh issue view #{number}")
|
|
114
|
+
when "repo_view"
|
|
115
|
+
run_gh("gh repo view")
|
|
116
|
+
when "release_list"
|
|
117
|
+
run_gh("gh release list --limit 10")
|
|
118
|
+
else
|
|
119
|
+
"Unknown GitHub action: #{action}"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def execute_api(action, args)
|
|
124
|
+
token = ENV["GITHUB_TOKEN"] || ENV.fetch("GH_TOKEN", nil)
|
|
125
|
+
return "Error: No GitHub authentication. Set GITHUB_TOKEN or install gh CLI." unless token
|
|
126
|
+
|
|
127
|
+
repo = args["repo"] || args[:repo] || detect_repo
|
|
128
|
+
|
|
129
|
+
case action
|
|
130
|
+
when "pr_list"
|
|
131
|
+
api_get("/repos/#{repo}/pulls?state=open&per_page=20", token)
|
|
132
|
+
when "pr_view"
|
|
133
|
+
number = args["number"] || args[:number]
|
|
134
|
+
api_get("/repos/#{repo}/pulls/#{number}", token)
|
|
135
|
+
when "issue_list"
|
|
136
|
+
api_get("/repos/#{repo}/issues?state=open&per_page=20", token)
|
|
137
|
+
when "issue_view"
|
|
138
|
+
number = args["number"] || args[:number]
|
|
139
|
+
api_get("/repos/#{repo}/issues/#{number}", token)
|
|
140
|
+
when "pr_create"
|
|
141
|
+
title = args["title"] || args[:title]
|
|
142
|
+
body_text = args["body"] || args[:body] || ""
|
|
143
|
+
base = args["base"] || args[:base] || "main"
|
|
144
|
+
head = current_branch
|
|
145
|
+
api_post("/repos/#{repo}/pulls", token, {
|
|
146
|
+
title: title, body: body_text, head: head, base: base
|
|
147
|
+
})
|
|
148
|
+
when "issue_create"
|
|
149
|
+
title = args["title"] || args[:title]
|
|
150
|
+
body_text = args["body"] || args[:body] || ""
|
|
151
|
+
api_post("/repos/#{repo}/issues", token, {
|
|
152
|
+
title: title, body: body_text
|
|
153
|
+
})
|
|
154
|
+
else
|
|
155
|
+
"Action '#{action}' requires gh CLI"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def run_gh(cmd)
|
|
160
|
+
output = `#{cmd} 2>&1`
|
|
161
|
+
output.empty? ? "(no output)" : output
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def api_get(path, token)
|
|
165
|
+
uri = URI("https://api.github.com#{path}")
|
|
166
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
167
|
+
http.use_ssl = true
|
|
168
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
169
|
+
request["Authorization"] = "Bearer #{token}"
|
|
170
|
+
request["Accept"] = "application/vnd.github+json"
|
|
171
|
+
|
|
172
|
+
response = http.request(request)
|
|
173
|
+
format_api_response(response)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def api_post(path, token, body)
|
|
177
|
+
uri = URI("https://api.github.com#{path}")
|
|
178
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
179
|
+
http.use_ssl = true
|
|
180
|
+
request = Net::HTTP::Post.new(uri.request_uri)
|
|
181
|
+
request["Authorization"] = "Bearer #{token}"
|
|
182
|
+
request["Accept"] = "application/vnd.github+json"
|
|
183
|
+
request["Content-Type"] = "application/json"
|
|
184
|
+
request.body = JSON.generate(body)
|
|
185
|
+
|
|
186
|
+
response = http.request(request)
|
|
187
|
+
format_api_response(response)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def format_api_response(response)
|
|
191
|
+
data = JSON.parse(response.body)
|
|
192
|
+
case data
|
|
193
|
+
when Array
|
|
194
|
+
data.first(10).map { |item| format_item(item) }.join("\n\n")
|
|
195
|
+
when Hash
|
|
196
|
+
if data["message"]
|
|
197
|
+
"API Error: #{data["message"]}"
|
|
198
|
+
else
|
|
199
|
+
format_item(data)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
rescue StandardError
|
|
203
|
+
response.body[0..500]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def format_item(item)
|
|
207
|
+
parts = []
|
|
208
|
+
parts << "##{item["number"]} #{item["title"]}" if item["number"]
|
|
209
|
+
parts << "State: #{item["state"]}" if item["state"]
|
|
210
|
+
parts << "URL: #{item["html_url"]}" if item["html_url"]
|
|
211
|
+
parts << "Author: #{item.dig("user", "login")}" if item.dig("user", "login")
|
|
212
|
+
parts.join("\n")
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def detect_repo
|
|
216
|
+
remote = `git remote get-url origin 2>/dev/null`.strip
|
|
217
|
+
if remote.match?(%r{github\.com[:/](.+?)(?:\.git)?$})
|
|
218
|
+
remote.match(%r{github\.com[:/](.+?)(?:\.git)?$})[1]
|
|
219
|
+
else
|
|
220
|
+
""
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def current_branch
|
|
225
|
+
`git branch --show-current 2>/dev/null`.strip
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def escape(str)
|
|
229
|
+
str.gsub("'", "'\\''")
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Tool for finding files by glob patterns.
|
|
6
|
+
# Returns matching file paths sorted by modification time.
|
|
7
|
+
class GlobTool < Base
|
|
8
|
+
def name
|
|
9
|
+
"glob"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def description
|
|
13
|
+
"Find files by glob pattern (e.g., '**/*.rb', 'src/**/*.ts'). " \
|
|
14
|
+
"Returns matching file paths sorted by modification time."
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def input_schema
|
|
18
|
+
{
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
pattern: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "The glob pattern to match files against (e.g., '**/*.rb')"
|
|
24
|
+
},
|
|
25
|
+
path: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Base directory to search in (defaults to current directory)"
|
|
28
|
+
},
|
|
29
|
+
max_results: {
|
|
30
|
+
type: "integer",
|
|
31
|
+
description: "Maximum number of results (default: 100)"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
required: %w[pattern]
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def risk_level
|
|
39
|
+
:low
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def call(arguments)
|
|
43
|
+
pattern = arguments["pattern"] || arguments[:pattern]
|
|
44
|
+
path = arguments["path"] || arguments[:path] || "."
|
|
45
|
+
max_results = arguments["max_results"] || arguments[:max_results] || 100
|
|
46
|
+
|
|
47
|
+
expanded_path = File.expand_path(path)
|
|
48
|
+
return "Error: Directory not found: #{path}" unless File.directory?(expanded_path)
|
|
49
|
+
|
|
50
|
+
full_pattern = File.join(expanded_path, pattern)
|
|
51
|
+
files = Dir.glob(full_pattern)
|
|
52
|
+
.select { |f| File.file?(f) }
|
|
53
|
+
.sort_by { |f| -File.mtime(f).to_i }
|
|
54
|
+
.first(max_results)
|
|
55
|
+
|
|
56
|
+
if files.empty?
|
|
57
|
+
"No files matched pattern: #{pattern}"
|
|
58
|
+
else
|
|
59
|
+
relative_files = files.map { |f| f.sub("#{expanded_path}/", "") }
|
|
60
|
+
full = "#{relative_files.size} file(s) found:\n\n#{relative_files.join("\n")}"
|
|
61
|
+
{ output: full,
|
|
62
|
+
metrics: "#{relative_files.size} file#{"s" if relative_files.size != 1}",
|
|
63
|
+
body: Util::Output.preview(full),
|
|
64
|
+
body_kind: :plain }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Tool for searching file contents using regex patterns.
|
|
6
|
+
# Backed by ripgrep (rg) if available, falls back to Ruby grep.
|
|
7
|
+
class GrepTool < Base
|
|
8
|
+
def name
|
|
9
|
+
"grep"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def description
|
|
13
|
+
"Search file contents using regular expressions. " \
|
|
14
|
+
"Returns matching file paths and line numbers. " \
|
|
15
|
+
"Supports include patterns to filter by file type."
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def input_schema
|
|
19
|
+
{
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
pattern: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description: "The regex pattern to search for"
|
|
25
|
+
},
|
|
26
|
+
path: {
|
|
27
|
+
type: "string",
|
|
28
|
+
description: "Directory to search in (defaults to current directory)"
|
|
29
|
+
},
|
|
30
|
+
include: {
|
|
31
|
+
type: "string",
|
|
32
|
+
description: "File pattern to include (e.g., '*.rb', '*.{ts,tsx}')"
|
|
33
|
+
},
|
|
34
|
+
max_results: {
|
|
35
|
+
type: "integer",
|
|
36
|
+
description: "Maximum number of results to return (default: 50)"
|
|
37
|
+
},
|
|
38
|
+
before: {
|
|
39
|
+
type: "integer",
|
|
40
|
+
description: "Lines of leading context to include before each match (-B). Default 0."
|
|
41
|
+
},
|
|
42
|
+
after: {
|
|
43
|
+
type: "integer",
|
|
44
|
+
description: "Lines of trailing context to include after each match (-A). Default 0."
|
|
45
|
+
},
|
|
46
|
+
context: {
|
|
47
|
+
type: "integer",
|
|
48
|
+
description: "Symmetric context (-C): sets both before and after. Wins over before/after when given."
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
required: %w[pattern]
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def risk_level
|
|
56
|
+
:low
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def call(arguments)
|
|
60
|
+
pattern = arguments["pattern"] || arguments[:pattern]
|
|
61
|
+
path = arguments["path"] || arguments[:path] || "."
|
|
62
|
+
include_pattern = arguments["include"] || arguments[:include]
|
|
63
|
+
max_results = arguments["max_results"] || arguments[:max_results] || 50
|
|
64
|
+
|
|
65
|
+
# -A/-B/-C semantics, mirroring ripgrep: `context` (-C) overrides
|
|
66
|
+
# both halves; otherwise each side defaults to 0. Clamp at 50 lines
|
|
67
|
+
# per side so a runaway model can't ask for 10_000 lines of context
|
|
68
|
+
# per match and overrun the output budget.
|
|
69
|
+
ctx = arguments["context"] || arguments[:context]
|
|
70
|
+
before = (ctx || arguments["before"] || arguments[:before] || 0).to_i.clamp(0, 50)
|
|
71
|
+
after = (ctx || arguments["after"] || arguments[:after] || 0).to_i.clamp(0, 50)
|
|
72
|
+
|
|
73
|
+
expanded_path = File.expand_path(path)
|
|
74
|
+
return "Error: Path not found: #{path}" unless File.exist?(expanded_path)
|
|
75
|
+
|
|
76
|
+
if ripgrep_available?
|
|
77
|
+
search_with_ripgrep(pattern, expanded_path, include_pattern, max_results, before, after)
|
|
78
|
+
else
|
|
79
|
+
search_with_ruby(pattern, expanded_path, include_pattern, max_results, before, after)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def ripgrep_available?
|
|
86
|
+
system("which rg > /dev/null 2>&1")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def search_with_ripgrep(pattern, path, include_pattern, max_results, before, after)
|
|
90
|
+
# Build argv array and use Open3 to avoid shell injection — pattern
|
|
91
|
+
# and path are passed as separate arguments, never interpolated into a
|
|
92
|
+
# shell string.
|
|
93
|
+
#
|
|
94
|
+
# NOTE: ripgrep has NO total-count flag — `--max-total-count` is not a
|
|
95
|
+
# real rg option and makes rg exit non-zero ("unrecognized flag"),
|
|
96
|
+
# which surfaced in prod as a wasted "Error executing search" turn.
|
|
97
|
+
# `--max-count` (-m) is PER-FILE, so it can't bound the total either.
|
|
98
|
+
# We therefore let rg run and cap the TOTAL number of result lines in
|
|
99
|
+
# Ruby below — true total cap, and it tames a pattern that matches
|
|
100
|
+
# thousands of lines in one file (the prod failure mode).
|
|
101
|
+
argv = ["rg", "--line-number", "--no-heading", "--color=never"]
|
|
102
|
+
argv += ["--glob=#{include_pattern}"] if include_pattern
|
|
103
|
+
argv += ["-B", before.to_s] if before.positive?
|
|
104
|
+
argv += ["-A", after.to_s] if after.positive?
|
|
105
|
+
argv += [pattern, path]
|
|
106
|
+
|
|
107
|
+
output = IO.popen(argv, err: %i[child out], &:read)
|
|
108
|
+
status = $?.exitstatus
|
|
109
|
+
|
|
110
|
+
if status == 0
|
|
111
|
+
all_lines = output.lines
|
|
112
|
+
lines = all_lines.first(max_results)
|
|
113
|
+
more = all_lines.size - lines.size
|
|
114
|
+
header = "#{lines.size} match(es) shown" \
|
|
115
|
+
"#{" (#{more} more — raise max_results or narrow the pattern)" if more.positive?}"
|
|
116
|
+
full = "#{header}:\n\n#{lines.join}"
|
|
117
|
+
{ output: full,
|
|
118
|
+
metrics: "#{lines.size} match#{"es" if lines.size != 1}#{"+" if more.positive?}",
|
|
119
|
+
body: Util::Output.preview(full),
|
|
120
|
+
body_kind: :plain }
|
|
121
|
+
elsif status == 1
|
|
122
|
+
"No matches found for pattern: #{pattern}"
|
|
123
|
+
else
|
|
124
|
+
"Error executing search: #{output}"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def search_with_ruby(pattern, path, include_pattern, max_results, before, after)
|
|
129
|
+
regex = Regexp.new(pattern)
|
|
130
|
+
results = []
|
|
131
|
+
|
|
132
|
+
# ripgrep accepts a single FILE as well as a directory; mirror that
|
|
133
|
+
# in the fallback. Dir.glob("<file>/**/*") yields nothing, so when
|
|
134
|
+
# `path` is a file we search it directly (include_pattern is moot).
|
|
135
|
+
files = File.file?(path) ? [path] : Dir.glob(File.join(path, "**", include_pattern || "*"))
|
|
136
|
+
|
|
137
|
+
files.each do |file|
|
|
138
|
+
next unless File.file?(file)
|
|
139
|
+
next if binary_file?(file)
|
|
140
|
+
|
|
141
|
+
begin
|
|
142
|
+
lines = File.readlines(file)
|
|
143
|
+
relative = file == path ? File.basename(file) : file.sub("#{path}/", "")
|
|
144
|
+
pending = 0 # lines remaining to emit after a match
|
|
145
|
+
last_idx = -1 # last line index already in results (to dedupe overlapping ctx)
|
|
146
|
+
separator_pending = false
|
|
147
|
+
lines.each_with_index do |line, idx|
|
|
148
|
+
matched = line.match?(regex)
|
|
149
|
+
if matched
|
|
150
|
+
# Emit `before` lines of context (skipping any already in results).
|
|
151
|
+
first_ctx = [idx - before, last_idx + 1].max
|
|
152
|
+
results << "--" if separator_pending && first_ctx > last_idx + 1
|
|
153
|
+
(first_ctx...idx).each do |ci|
|
|
154
|
+
results << "#{relative}:#{ci + 1}- #{lines[ci].rstrip}"
|
|
155
|
+
last_idx = ci
|
|
156
|
+
end
|
|
157
|
+
results << "#{relative}:#{idx + 1}: #{line.rstrip}"
|
|
158
|
+
last_idx = idx
|
|
159
|
+
pending = after
|
|
160
|
+
separator_pending = false
|
|
161
|
+
break if results.size >= max_results
|
|
162
|
+
elsif pending.positive?
|
|
163
|
+
results << "#{relative}:#{idx + 1}- #{line.rstrip}"
|
|
164
|
+
last_idx = idx
|
|
165
|
+
pending -= 1
|
|
166
|
+
separator_pending = pending.zero?
|
|
167
|
+
break if results.size >= max_results
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
rescue StandardError
|
|
171
|
+
next
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
break if results.size >= max_results
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
if results.empty?
|
|
178
|
+
"No matches found for pattern: #{pattern}"
|
|
179
|
+
else
|
|
180
|
+
# We stop scanning once results hits max_results, so a full cap means
|
|
181
|
+
# more matches may exist — flag it the same way the ripgrep path does.
|
|
182
|
+
capped = results.size >= max_results
|
|
183
|
+
match_count = results.count { |l| l.include?(":") && l !~ /:\d+- / && l != "--" }
|
|
184
|
+
header = "#{match_count} match(es) shown" \
|
|
185
|
+
"#{" (more may exist — raise max_results or narrow the pattern)" if capped}"
|
|
186
|
+
full = "#{header}:\n\n#{results.join("\n")}"
|
|
187
|
+
{ output: full,
|
|
188
|
+
metrics: "#{match_count} match#{"es" if match_count != 1}#{"+" if capped}",
|
|
189
|
+
body: Util::Output.preview(full),
|
|
190
|
+
body_kind: :plain }
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def binary_file?(path)
|
|
195
|
+
sample = begin
|
|
196
|
+
File.read(path, 512)
|
|
197
|
+
rescue StandardError
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
return true unless sample
|
|
201
|
+
|
|
202
|
+
sample.include?("\x00")
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Tools
|
|
5
|
+
# Agent-callable interface to the memory store.
|
|
6
|
+
#
|
|
7
|
+
# The agent uses this to record durable facts about the user or
|
|
8
|
+
# project across sessions. The schema is deliberately tiny — three
|
|
9
|
+
# actions, two targets — because every additional knob is another
|
|
10
|
+
# surface a prompt-injection attempt can probe. Threat scanning and
|
|
11
|
+
# the char-budget run inside Memory::Store; this tool only handles
|
|
12
|
+
# the action/target mapping and translates Store exceptions into
|
|
13
|
+
# tool-protocol error strings.
|
|
14
|
+
class MemoryTool < Base
|
|
15
|
+
VALID_ACTIONS = %w[add replace remove].freeze
|
|
16
|
+
VALID_TARGETS = %w[memory user].freeze
|
|
17
|
+
|
|
18
|
+
# target → memory kind. "user" is the user_profile slot; "memory"
|
|
19
|
+
# is the catch-all "fact" kind. Other kinds (preference,
|
|
20
|
+
# technical_decision, …) are reserved for the auto-extractor — the
|
|
21
|
+
# agent does not get to write to them directly through this tool.
|
|
22
|
+
TARGET_TO_KIND = { "memory" => "fact", "user" => "user_profile" }.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(backend: nil)
|
|
25
|
+
@backend = backend
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def name
|
|
29
|
+
"memory"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def description
|
|
33
|
+
"Persist facts across sessions. Use action=add to record a new fact, " \
|
|
34
|
+
"replace to update an existing fact (substring match on old_text), " \
|
|
35
|
+
"or remove to delete one. target=user writes to the user profile; " \
|
|
36
|
+
"target=memory writes to general memory. " \
|
|
37
|
+
"Store ONE atomic fact per call — make separate calls for separate " \
|
|
38
|
+
"facts so each can be superseded or forgotten independently. " \
|
|
39
|
+
"Content is scanned for prompt-injection / exfiltration patterns and " \
|
|
40
|
+
"subject to a character budget — refusals are reported in the output."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def input_schema
|
|
44
|
+
{
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
action: {
|
|
48
|
+
type: "string",
|
|
49
|
+
enum: VALID_ACTIONS,
|
|
50
|
+
description: "add, replace, or remove"
|
|
51
|
+
},
|
|
52
|
+
target: {
|
|
53
|
+
type: "string",
|
|
54
|
+
enum: VALID_TARGETS,
|
|
55
|
+
description: "memory (general) or user (user profile)"
|
|
56
|
+
},
|
|
57
|
+
content: {
|
|
58
|
+
type: "string",
|
|
59
|
+
description: "New content (required for add and replace)"
|
|
60
|
+
},
|
|
61
|
+
old_text: {
|
|
62
|
+
type: "string",
|
|
63
|
+
description: "Substring of existing memory to match " \
|
|
64
|
+
"(required for replace and remove)"
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
required: %w[action target]
|
|
68
|
+
}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def risk_level
|
|
72
|
+
# Memory store/retrieve/update is an internal, low-risk operation:
|
|
73
|
+
# an autonomous "scratchpad" the agent maintains, not an external
|
|
74
|
+
# side-effect like editing the user's files or running a shell
|
|
75
|
+
# command. It must not trip the approval gate. Every write is
|
|
76
|
+
# already threat-scanned and char-budgeted inside Memory::Store,
|
|
77
|
+
# and the only destructive action (remove) deletes a SINGLE entry
|
|
78
|
+
# by substring match — there is no full-wipe op exposed here — so
|
|
79
|
+
# there is nothing left for an approval prompt to guard.
|
|
80
|
+
# :low keeps it autonomous even under approvals.mode: manual
|
|
81
|
+
# (Base#risky? only flags :medium/:high), matching how todo_tool
|
|
82
|
+
# and other internal state-mutating tools stay unprompted.
|
|
83
|
+
:low
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def call(arguments)
|
|
87
|
+
args = symbolize(arguments)
|
|
88
|
+
action = args[:action].to_s
|
|
89
|
+
target = args[:target].to_s
|
|
90
|
+
|
|
91
|
+
return error("invalid action '#{action}'; expected one of #{VALID_ACTIONS.join(", ")}") \
|
|
92
|
+
unless VALID_ACTIONS.include?(action)
|
|
93
|
+
return error("invalid target '#{target}'; expected one of #{VALID_TARGETS.join(", ")}") \
|
|
94
|
+
unless VALID_TARGETS.include?(target)
|
|
95
|
+
|
|
96
|
+
kind = TARGET_TO_KIND.fetch(target)
|
|
97
|
+
|
|
98
|
+
case action
|
|
99
|
+
when "add" then do_add(kind, args[:content])
|
|
100
|
+
when "replace" then do_replace(kind, args[:old_text], args[:content])
|
|
101
|
+
when "remove" then do_remove(kind, args[:old_text])
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def backend
|
|
108
|
+
@backend ||= Memory::Backends.build
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def do_add(kind, content)
|
|
112
|
+
return error("content is required for add") if blank?(content)
|
|
113
|
+
|
|
114
|
+
memory = backend.store(kind: kind, content: content)
|
|
115
|
+
"Memory added (id=#{memory[:id][0, 8]}, kind=#{kind})."
|
|
116
|
+
rescue Memory::Store::ThreatDetectedError => e
|
|
117
|
+
threat_error(e)
|
|
118
|
+
rescue Memory::Store::BudgetExceededError => e
|
|
119
|
+
budget_error(e)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def do_replace(kind, old_text, content)
|
|
123
|
+
return error("old_text is required for replace") if blank?(old_text)
|
|
124
|
+
return error("content is required for replace") if blank?(content)
|
|
125
|
+
|
|
126
|
+
target = backend.replace(kind: kind, old_text: old_text, content: content)
|
|
127
|
+
return error("no #{kind} memory matched substring '#{truncate(old_text)}'") unless target
|
|
128
|
+
|
|
129
|
+
"Memory replaced (id=#{target[:id][0, 8]}, kind=#{kind})."
|
|
130
|
+
rescue Memory::Store::ThreatDetectedError => e
|
|
131
|
+
threat_error(e)
|
|
132
|
+
rescue Memory::Store::BudgetExceededError => e
|
|
133
|
+
budget_error(e)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def do_remove(kind, old_text)
|
|
137
|
+
return error("old_text is required for remove") if blank?(old_text)
|
|
138
|
+
|
|
139
|
+
target = backend.forget(kind: kind, old_text: old_text)
|
|
140
|
+
return error("no #{kind} memory matched substring '#{truncate(old_text)}'") unless target
|
|
141
|
+
|
|
142
|
+
"Memory removed (id=#{target[:id][0, 8]}, kind=#{kind})."
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def threat_error(err)
|
|
146
|
+
{
|
|
147
|
+
output: "Error: refused to write memory (#{err.threat}). " \
|
|
148
|
+
"Memory content was rejected by the threat scanner.",
|
|
149
|
+
error_code: :memory_threat_detected
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def budget_error(err)
|
|
154
|
+
{
|
|
155
|
+
output: "Error: memory budget exceeded; delete or replace older entries first " \
|
|
156
|
+
"(group=#{err.group}, used=#{err.current}, requested=#{err.requested}, " \
|
|
157
|
+
"limit=#{err.limit}).",
|
|
158
|
+
error_code: :memory_budget_exceeded
|
|
159
|
+
}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def error(msg)
|
|
163
|
+
"Error: #{msg}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def blank?(value)
|
|
167
|
+
value.nil? || value.to_s.strip.empty?
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def truncate(text, max: 40)
|
|
171
|
+
s = text.to_s
|
|
172
|
+
s.length > max ? "#{s[0, max]}..." : s
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def symbolize(arguments)
|
|
176
|
+
return {} unless arguments.is_a?(Hash)
|
|
177
|
+
|
|
178
|
+
arguments.each_with_object({}) do |(k, v), acc|
|
|
179
|
+
acc[k.to_sym] = v
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|