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,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Skills
|
|
5
|
+
# Discovers and manages skills from configured paths.
|
|
6
|
+
# Skills are loaded lazily - metadata is parsed upfront but
|
|
7
|
+
# full content is only loaded when the skill is invoked.
|
|
8
|
+
class Registry
|
|
9
|
+
# Flat-file skills: <dir>/<name>.md (legacy, kept for back-compat).
|
|
10
|
+
FLAT_GLOB = "*.md"
|
|
11
|
+
# Directory skills: <dir>/<name>/SKILL.md (Claude skill layout).
|
|
12
|
+
DIR_GLOB = File.join("*", "SKILL.md")
|
|
13
|
+
|
|
14
|
+
# Skills shipped *inside the gem* (skills/<name>/SKILL.md at the gem
|
|
15
|
+
# root, packaged via the gemspec's git-ls-files list). These are
|
|
16
|
+
# ALWAYS discovered — they don't depend on the user's skills.paths
|
|
17
|
+
# config (which `setup` freezes into config.yml) and they survive the
|
|
18
|
+
# folder-trust filter because this is an absolute path under the
|
|
19
|
+
# installed gem, owned by the user, not anything a visited repo can
|
|
20
|
+
# influence. This is how built-in skills (e.g. ruby-expert) reach every
|
|
21
|
+
# install with no copy step and update automatically on gem upgrade.
|
|
22
|
+
BUILTIN_SKILLS_DIR = File.expand_path("../../../skills", __dir__)
|
|
23
|
+
|
|
24
|
+
# +include_project_local+ controls whether the cwd `.rubino/skills`
|
|
25
|
+
# catalogue is discovered. Folder-trust passes false for an UNtrusted
|
|
26
|
+
# primary root so a hostile repo's skill descriptions can't be auto-
|
|
27
|
+
# injected into the system prompt before the user vouches for the folder
|
|
28
|
+
# (the home `~/.rubino/skills` catalogue is always loaded — it's the
|
|
29
|
+
# user's own, not attacker-controllable by cd-ing into a repo).
|
|
30
|
+
# +include_builtin+ controls whether the gem-bundled BUILTIN_SKILLS_DIR is
|
|
31
|
+
# scanned. Always on in production (built-ins ship with every install).
|
|
32
|
+
# When left nil it falls back to the `skills.include_builtin` config key
|
|
33
|
+
# (default true), so a caller that only has the config — like the prompt
|
|
34
|
+
# assembler, which builds its own Registry — can still opt out; tests that
|
|
35
|
+
# assert an exact catalogue pass false to isolate from the shipped skills.
|
|
36
|
+
def initialize(config: nil, state_repository: nil, include_project_local: true, include_builtin: nil)
|
|
37
|
+
@config = config || Rubino.configuration
|
|
38
|
+
@state_repository = state_repository
|
|
39
|
+
@include_project_local = include_project_local
|
|
40
|
+
@include_builtin = include_builtin.nil? ? (@config.dig("skills", "include_builtin") != false) : include_builtin
|
|
41
|
+
@skills = {}
|
|
42
|
+
@discovered = false
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A registry aligned with the prompt assembler's folder-trust gate (#63):
|
|
46
|
+
# in an untrusted cwd the project-local catalogue is excluded, so the
|
|
47
|
+
# /skills picker and activation surface only skills the assembler will
|
|
48
|
+
# actually pin into the system prompt — never a chip claiming an active
|
|
49
|
+
# skill whose SKILL.md is withheld.
|
|
50
|
+
def self.trusted(**)
|
|
51
|
+
new(include_project_local: project_local_trusted?, **)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Mirrors Context::PromptAssembler#project_local_trusted?: trust-gate the
|
|
55
|
+
# cwd, but never let the check itself break discovery on a real error.
|
|
56
|
+
def self.project_local_trusted?
|
|
57
|
+
Rubino::Trust.trusted?(Rubino::Workspace.primary_root)
|
|
58
|
+
rescue StandardError
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Discovers all available skills from configured paths. Both the flat
|
|
63
|
+
# layout (<name>.md) and the directory layout (<name>/SKILL.md) are
|
|
64
|
+
# supported. When a name collides, the directory skill wins (it is the
|
|
65
|
+
# richer unit: it can carry bundled references/scripts/assets).
|
|
66
|
+
def discover!
|
|
67
|
+
previously_discovered = @discovered
|
|
68
|
+
known_before = @skills.keys
|
|
69
|
+
@skills.clear
|
|
70
|
+
skill_paths.each do |dir|
|
|
71
|
+
expanded = resolve_path(dir)
|
|
72
|
+
next unless File.directory?(expanded)
|
|
73
|
+
|
|
74
|
+
add_skills(Dir.glob(File.join(expanded, FLAT_GLOB)))
|
|
75
|
+
add_skills(Dir.glob(File.join(expanded, DIR_GLOB)))
|
|
76
|
+
end
|
|
77
|
+
@discovered = true
|
|
78
|
+
# Skill CREATION has no in-process tool — the agent writes files — so the
|
|
79
|
+
# cleanest available signal is a RE-scan surfacing a skill we hadn't seen
|
|
80
|
+
# before. Only count on a re-discover (not the first scan, which is just
|
|
81
|
+
# initial enumeration) so existing skills aren't booked as "created".
|
|
82
|
+
count_created!(known_before) if previously_discovered
|
|
83
|
+
@skills
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Returns all discovered skills (discovers on first call)
|
|
87
|
+
def all
|
|
88
|
+
discover! unless @discovered
|
|
89
|
+
@skills.values
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Finds a skill by name
|
|
93
|
+
def find(name)
|
|
94
|
+
discover! unless @discovered
|
|
95
|
+
@skills[name.to_s]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Returns skill summaries for prompt inclusion (names + descriptions only).
|
|
99
|
+
# Disabled skills (per StateRepository) are excluded so a skill toggled
|
|
100
|
+
# off never appears in the system-prompt index (Skills::PromptIndex).
|
|
101
|
+
def summaries
|
|
102
|
+
enabled.map(&:summary)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Loads and returns the full content of a skill by name. Returns nil when
|
|
106
|
+
# the skill is unknown; the disabled case is surfaced by #enabled? so the
|
|
107
|
+
# caller (SkillTool) can give a distinct "disabled" message.
|
|
108
|
+
def load_skill(name)
|
|
109
|
+
skill = find(name)
|
|
110
|
+
return nil unless skill
|
|
111
|
+
|
|
112
|
+
skill.content
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Returns skill names
|
|
116
|
+
def names
|
|
117
|
+
all.map(&:name)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Skills not toggled off in the StateRepository (default-enabled when no
|
|
121
|
+
# row exists). Single source of truth for the enabled-filter shared by the
|
|
122
|
+
# system-prompt index (via #summaries) and the `skill` tool (via this).
|
|
123
|
+
def enabled
|
|
124
|
+
all.select { |skill| enabled?(skill.name) }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Whether a skill is enabled (default-enabled when no state row exists).
|
|
128
|
+
def enabled?(name)
|
|
129
|
+
state_repository.enabled?(name)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private
|
|
133
|
+
|
|
134
|
+
# Increments +skills_created_total+ once per skill name that appears in a
|
|
135
|
+
# re-scan but was absent from the prior scan. NOTE: this is the only clean
|
|
136
|
+
# in-process creation signal available (there is no skill-creation tool);
|
|
137
|
+
# a skill created on disk is therefore counted the next time the registry
|
|
138
|
+
# re-discovers, not at write time. A skill removed and re-added would be
|
|
139
|
+
# re-counted — acceptable for a usage signal, not a ledger.
|
|
140
|
+
def count_created!(known_before)
|
|
141
|
+
new_names = @skills.keys - known_before
|
|
142
|
+
return if new_names.empty?
|
|
143
|
+
|
|
144
|
+
Rubino::Metrics.counter(:skills_created_total).increment(by: new_names.size)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def state_repository
|
|
148
|
+
@state_repository ||= StateRepository.new
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Resolves a configured skills dir to an absolute path. The stock
|
|
152
|
+
# "~/.rubino/..." entries follow the resolved home (RUBINO_HOME → else
|
|
153
|
+
# ~/.rubino), same resolver config/.env/DB/commands use, so an isolated
|
|
154
|
+
# home actually has its skills discovered (#135) instead of the literal
|
|
155
|
+
# path expanding against the REAL home. Any other path expands verbatim.
|
|
156
|
+
def resolve_path(dir)
|
|
157
|
+
if dir.to_s == "~/.rubino" || dir.to_s.start_with?("~/.rubino/")
|
|
158
|
+
File.join(Config::Loader.default_home_path, dir.to_s.delete_prefix("~/.rubino"))
|
|
159
|
+
else
|
|
160
|
+
File.expand_path(dir)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Builds a Skill per path and indexes it by name. Called with flat paths
|
|
165
|
+
# first, then directory paths, so directory skills override flat ones on
|
|
166
|
+
# a name collision (see #discover!).
|
|
167
|
+
def add_skills(paths)
|
|
168
|
+
paths.each do |path|
|
|
169
|
+
skill = Skill.new(path: path)
|
|
170
|
+
@skills[skill.name] = skill
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def skill_paths
|
|
175
|
+
paths = @config.dig("skills", "paths") || [
|
|
176
|
+
".rubino/skills",
|
|
177
|
+
"~/.rubino/skills"
|
|
178
|
+
]
|
|
179
|
+
unless @include_project_local
|
|
180
|
+
# Untrusted primary root: drop the project-local (cwd-relative) skill
|
|
181
|
+
# dirs, keeping only absolute / home (~) paths the user controls.
|
|
182
|
+
paths = paths.reject { |p| project_local_path?(p) }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Built-in (gem-bundled) skills are scanned FIRST so a user skill of the
|
|
186
|
+
# same name — discovered later in .rubino/skills or ~/.rubino/skills —
|
|
187
|
+
# overrides the built-in on the registry's name-indexed merge (last
|
|
188
|
+
# writer wins in #add_skills). That lets a user shadow/customize a
|
|
189
|
+
# shipped skill while still getting the built-ins for free by default.
|
|
190
|
+
@include_builtin ? [BUILTIN_SKILLS_DIR, *paths] : paths
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A skill path is "project-local" when it resolves under the primary
|
|
194
|
+
# workspace root (the cwd a hostile repo could ship skills in), as
|
|
195
|
+
# opposed to an absolute or ~/.rubino path the user owns.
|
|
196
|
+
def project_local_path?(path)
|
|
197
|
+
return false if path.to_s.start_with?("~", "/")
|
|
198
|
+
|
|
199
|
+
expanded = File.expand_path(path.to_s)
|
|
200
|
+
root = File.expand_path(Workspace.primary_root)
|
|
201
|
+
expanded == root || expanded.start_with?("#{root}#{File::SEPARATOR}")
|
|
202
|
+
rescue StandardError
|
|
203
|
+
# Conservative: if we can't tell, treat as project-local and drop it.
|
|
204
|
+
true
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Skills
|
|
7
|
+
# Represents a single skill. Two layouts are supported:
|
|
8
|
+
# * flat file — <dir>/<name>.md (the skill name is the basename)
|
|
9
|
+
# * directory — <dir>/<name>/SKILL.md (the skill name is the dir name,
|
|
10
|
+
# plus bundled files under references/ scripts/ assets/ etc.)
|
|
11
|
+
#
|
|
12
|
+
# In both cases `path` points at the markdown body that carries the
|
|
13
|
+
# name/description frontmatter. Directory skills also expose `linked_files`
|
|
14
|
+
# (relative paths of bundled files) and can read a specific bundled file
|
|
15
|
+
# sandboxed to the skill's own directory.
|
|
16
|
+
class Skill
|
|
17
|
+
attr_reader :name, :description, :path, :metadata, :linked_files
|
|
18
|
+
|
|
19
|
+
def initialize(path:)
|
|
20
|
+
@path = path
|
|
21
|
+
@metadata = {}
|
|
22
|
+
@content = nil
|
|
23
|
+
@linked_files = []
|
|
24
|
+
@directory = directory_skill? ? File.dirname(path) : nil
|
|
25
|
+
discover_linked_files! if directory?
|
|
26
|
+
parse_frontmatter!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# True when this skill is backed by a <name>/SKILL.md directory.
|
|
30
|
+
def directory?
|
|
31
|
+
!@directory.nil?
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The skill's own directory (only for directory skills).
|
|
35
|
+
def dir
|
|
36
|
+
@directory
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Returns the full skill content (loaded lazily)
|
|
40
|
+
def content
|
|
41
|
+
@content ||= load_content
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Returns true if the skill has been fully loaded
|
|
45
|
+
def loaded?
|
|
46
|
+
!@content.nil?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Reads a bundled file by its relative path, sandboxed to the skill dir.
|
|
50
|
+
# Returns the file contents, or nil if the skill has no directory, the
|
|
51
|
+
# path escapes the skill dir, or the file does not exist.
|
|
52
|
+
#
|
|
53
|
+
# Resolve and read happen back-to-back with no listing step in between, so
|
|
54
|
+
# the caller can't observe a "present in the listing but unreadable" state
|
|
55
|
+
# from THIS method. A File::ENOENT between #file? and #read (the skill dir
|
|
56
|
+
# being torn down mid-call) is swallowed to nil rather than raised, so a
|
|
57
|
+
# concurrent teardown reads as a clean miss instead of a crash (W3).
|
|
58
|
+
def read_file(relative_path)
|
|
59
|
+
return nil unless directory?
|
|
60
|
+
|
|
61
|
+
resolved = resolve_within_dir(relative_path)
|
|
62
|
+
return nil unless resolved && File.file?(resolved)
|
|
63
|
+
|
|
64
|
+
File.read(resolved)
|
|
65
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Live relative paths of bundled files, recomputed from disk. Unlike the
|
|
70
|
+
# +linked_files+ snapshot taken at init, this reflects the current dir
|
|
71
|
+
# state — so an error message built from it can't list a file that
|
|
72
|
+
# #read_file just failed to find (the W3 self-contradiction). Empty for
|
|
73
|
+
# flat-file skills.
|
|
74
|
+
def current_linked_files
|
|
75
|
+
return [] unless directory?
|
|
76
|
+
|
|
77
|
+
collect_linked_files
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns a summary for the agent to see available skills
|
|
81
|
+
def summary
|
|
82
|
+
"#{@name}: #{@description}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def directory_skill?
|
|
88
|
+
File.basename(@path) == "SKILL.md"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Caches the init-time snapshot in +@linked_files+ (the system-prompt
|
|
92
|
+
# hint shows this). The live recompute path uses #collect_linked_files
|
|
93
|
+
# directly so the two never drift in logic.
|
|
94
|
+
def discover_linked_files!
|
|
95
|
+
@linked_files = collect_linked_files
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Relative paths of bundled files under the skill dir, excluding SKILL.md
|
|
99
|
+
# itself and vcs/junk dirs. Sorted for deterministic output. Re-globs the
|
|
100
|
+
# directory on every call, so it reflects current disk state.
|
|
101
|
+
def collect_linked_files
|
|
102
|
+
files = Dir.glob(File.join(@directory, "**", "*"), File::FNM_DOTMATCH).filter_map do |entry|
|
|
103
|
+
next unless File.file?(entry)
|
|
104
|
+
|
|
105
|
+
rel = entry.delete_prefix("#{@directory}#{File::SEPARATOR}")
|
|
106
|
+
next if rel == "SKILL.md"
|
|
107
|
+
next if rel.split(File::SEPARATOR).any? { |seg| EXCLUDED_DIRS.include?(seg) }
|
|
108
|
+
|
|
109
|
+
rel
|
|
110
|
+
end
|
|
111
|
+
files.sort
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Resolves a relative path against the skill dir and rejects anything that
|
|
115
|
+
# escapes it (via .., absolute paths, or symlinks pointing outside).
|
|
116
|
+
def resolve_within_dir(relative_path)
|
|
117
|
+
return nil if relative_path.nil? || relative_path.to_s.empty?
|
|
118
|
+
|
|
119
|
+
root = File.realpath(@directory)
|
|
120
|
+
target = File.expand_path(relative_path.to_s, root)
|
|
121
|
+
candidate = File.exist?(target) ? File.realpath(target) : target
|
|
122
|
+
|
|
123
|
+
return nil unless candidate == root || candidate.start_with?("#{root}#{File::SEPARATOR}")
|
|
124
|
+
|
|
125
|
+
candidate
|
|
126
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def parse_frontmatter!
|
|
131
|
+
raw = File.read(@path)
|
|
132
|
+
|
|
133
|
+
if raw.start_with?("---")
|
|
134
|
+
parts = raw.split("---", 3)
|
|
135
|
+
if parts.size >= 3
|
|
136
|
+
begin
|
|
137
|
+
@metadata = YAML.safe_load(parts[1], permitted_classes: [Symbol]) || {}
|
|
138
|
+
rescue Psych::SyntaxError => e
|
|
139
|
+
warn "rubino: skipping malformed frontmatter in #{@path} " \
|
|
140
|
+
"(line #{e.line}: #{e.problem})"
|
|
141
|
+
@metadata = {}
|
|
142
|
+
end
|
|
143
|
+
@metadata = {} unless @metadata.is_a?(Hash)
|
|
144
|
+
@name = (@metadata["name"] || default_name).to_s
|
|
145
|
+
@description = @metadata["description"] || ""
|
|
146
|
+
else
|
|
147
|
+
@name = default_name
|
|
148
|
+
@description = ""
|
|
149
|
+
end
|
|
150
|
+
else
|
|
151
|
+
@name = default_name
|
|
152
|
+
@description = raw.lines.first&.strip&.sub(/^#\s*/, "") || ""
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# For a directory skill the name is the directory name; for a flat file
|
|
157
|
+
# it is the markdown basename.
|
|
158
|
+
def default_name
|
|
159
|
+
directory? ? File.basename(@directory) : File.basename(@path, ".md")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def load_content
|
|
163
|
+
raw = File.read(@path)
|
|
164
|
+
|
|
165
|
+
if raw.start_with?("---")
|
|
166
|
+
parts = raw.split("---", 3)
|
|
167
|
+
parts.size >= 3 ? parts[2].strip : raw
|
|
168
|
+
else
|
|
169
|
+
raw
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
EXCLUDED_DIRS = %w[.git .svn .hg node_modules __pycache__ .DS_Store].freeze
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Skills
|
|
7
|
+
# Tool that allows the agent to load a skill on demand, and (Variant A —
|
|
8
|
+
# reference-style affordance) to CREATE a new skill inline during the turn.
|
|
9
|
+
#
|
|
10
|
+
# The agent sees skill names/descriptions in the system prompt and can invoke
|
|
11
|
+
# this tool to load the full skill instructions into context, or — after a
|
|
12
|
+
# complex, repeatable task — to distil what it just did into a new skill with
|
|
13
|
+
# action: "create" (0 extra LLM calls; the create happens inline on the
|
|
14
|
+
# tool-call the model already emitted).
|
|
15
|
+
class SkillTool < Tools::Base
|
|
16
|
+
# kebab-case, <=64 chars, mirrors the skill-creator frontmatter contract.
|
|
17
|
+
NAME_RE = /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/
|
|
18
|
+
|
|
19
|
+
def initialize(registry: nil)
|
|
20
|
+
@registry = registry || Registry.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def name
|
|
24
|
+
"skill"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def description
|
|
28
|
+
"Load a specialized skill's instructions into context, or create a new " \
|
|
29
|
+
"skill. action defaults to \"load\": use it when a task matches one of " \
|
|
30
|
+
"the available skills listed under \"## Skills\" in the system prompt " \
|
|
31
|
+
"(pass file_path to load a bundled file). After finishing a complex, " \
|
|
32
|
+
"multi-step task (typically 5+ tool calls) that is likely to recur and " \
|
|
33
|
+
"isn't already covered, call action: \"create\" with name, description, " \
|
|
34
|
+
"and body to save it as a reusable skill."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def input_schema
|
|
38
|
+
{
|
|
39
|
+
type: "object",
|
|
40
|
+
properties: {
|
|
41
|
+
action: {
|
|
42
|
+
type: "string",
|
|
43
|
+
enum: %w[load create],
|
|
44
|
+
description: "\"load\" (default) loads an existing skill; " \
|
|
45
|
+
"\"create\" writes a new skill from name/description/body."
|
|
46
|
+
},
|
|
47
|
+
name: {
|
|
48
|
+
type: "string",
|
|
49
|
+
description: "The skill name. For load: the skill to load. " \
|
|
50
|
+
"For create: a kebab-case name (<=64 chars)."
|
|
51
|
+
},
|
|
52
|
+
file_path: {
|
|
53
|
+
type: "string",
|
|
54
|
+
description: "Optional (load only). Relative path of a bundled file within the " \
|
|
55
|
+
"skill (e.g. 'references/api.md', 'scripts/run.py') to load " \
|
|
56
|
+
"its contents. Use the linked_files listed when the skill " \
|
|
57
|
+
"body is first loaded."
|
|
58
|
+
},
|
|
59
|
+
description: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description: "Required for create. One line: what the skill is for and WHEN " \
|
|
62
|
+
"it applies (the only text future runs see before loading it)."
|
|
63
|
+
},
|
|
64
|
+
body: {
|
|
65
|
+
type: "string",
|
|
66
|
+
description: "Required for create. The markdown body: proven step-by-step " \
|
|
67
|
+
"instructions, commands, and pitfalls. Be specific and prescriptive."
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
required: %w[name]
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def risk_level
|
|
75
|
+
:low
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# action: "load" (default) — three-level progressive disclosure:
|
|
79
|
+
# skill(name) -> Level 2: SKILL.md body
|
|
80
|
+
# skill(name, file_path: "ref.md") -> Level 3: one bundled file
|
|
81
|
+
# action: "create" — write a new <name>/SKILL.md inline (Variant A).
|
|
82
|
+
def call(arguments)
|
|
83
|
+
action = (arguments["action"] || arguments[:action] || "load").to_s
|
|
84
|
+
return create(arguments) if action == "create"
|
|
85
|
+
|
|
86
|
+
skill_name = arguments["name"] || arguments[:name]
|
|
87
|
+
file_path = arguments["file_path"] || arguments[:file_path]
|
|
88
|
+
|
|
89
|
+
skill = @registry.find(skill_name)
|
|
90
|
+
return not_found(skill_name) unless skill
|
|
91
|
+
return disabled(skill_name) unless @registry.enabled?(skill_name)
|
|
92
|
+
|
|
93
|
+
return load_bundled_file(skill, skill_name, file_path) if file_path && !file_path.to_s.empty?
|
|
94
|
+
|
|
95
|
+
load_body(skill, skill_name)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
# ---- create (Variant A: inline, 0 extra LLM calls) --------------------
|
|
101
|
+
|
|
102
|
+
def create(arguments)
|
|
103
|
+
skill_name = (arguments["name"] || arguments[:name]).to_s.strip
|
|
104
|
+
description = (arguments["description"] || arguments[:description]).to_s.strip
|
|
105
|
+
body = (arguments["body"] || arguments[:body]).to_s
|
|
106
|
+
|
|
107
|
+
err = validate_create(skill_name, description, body)
|
|
108
|
+
return err if err
|
|
109
|
+
|
|
110
|
+
return duplicate(skill_name) if @registry.find(skill_name)
|
|
111
|
+
|
|
112
|
+
path = write_skill(skill_name, description, body)
|
|
113
|
+
# Re-discover so the new skill is immediately usable. The disk-diff in
|
|
114
|
+
# Registry#discover! is the SINGLE source of truth for
|
|
115
|
+
# skills_created_total — it books the just-written skill on this re-scan,
|
|
116
|
+
# so we must NOT increment the counter inline here too (that would
|
|
117
|
+
# double-count one creation).
|
|
118
|
+
@registry.discover!
|
|
119
|
+
Rubino.active_event_bus&.emit(
|
|
120
|
+
Interaction::Events::SKILL_CREATED,
|
|
121
|
+
name: skill_name, file_path: path
|
|
122
|
+
)
|
|
123
|
+
"Created skill '#{skill_name}' at #{path}. It is now available to load " \
|
|
124
|
+
"with skill(name: \"#{skill_name}\")."
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
"Could not create skill '#{skill_name}': #{e.message}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def validate_create(skill_name, description, body)
|
|
130
|
+
return "Cannot create skill: name is required." if skill_name.empty?
|
|
131
|
+
unless skill_name.match?(NAME_RE) && skill_name.length <= 64
|
|
132
|
+
return "Cannot create skill: name must be kebab-case (lowercase letters, " \
|
|
133
|
+
"digits, hyphens) and <=64 chars; got #{skill_name.inspect}."
|
|
134
|
+
end
|
|
135
|
+
return "Cannot create skill: description is required." if description.empty?
|
|
136
|
+
return "Cannot create skill: description must be <=1024 chars." if description.length > 1024
|
|
137
|
+
return "Cannot create skill: body is required." if body.strip.empty?
|
|
138
|
+
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def write_skill(skill_name, description, body)
|
|
143
|
+
dir = File.join(skills_write_dir, skill_name)
|
|
144
|
+
FileUtils.mkdir_p(dir)
|
|
145
|
+
path = File.join(dir, "SKILL.md")
|
|
146
|
+
content = "---\nname: #{skill_name}\ndescription: #{yaml_scalar(description)}\n---\n\n"
|
|
147
|
+
content << body
|
|
148
|
+
content << "\n" unless content.end_with?("\n")
|
|
149
|
+
File.write(path, content)
|
|
150
|
+
path
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Quote the description so a colon/newline can't break the YAML frontmatter.
|
|
154
|
+
def yaml_scalar(text)
|
|
155
|
+
one_line = text.tr("\n", " ").strip
|
|
156
|
+
%("#{one_line.gsub('"', '\\"')}")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# First configured skills path (project-local .rubino/skills by
|
|
160
|
+
# default) — the same source the Registry discovers from, so a created
|
|
161
|
+
# skill is found on the immediate re-discover.
|
|
162
|
+
def skills_write_dir
|
|
163
|
+
dir = (Rubino.configuration.dig("skills", "paths") || [".rubino/skills"]).first
|
|
164
|
+
File.expand_path(dir.to_s)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def duplicate(skill_name)
|
|
168
|
+
"A skill named '#{skill_name}' already exists; not overwriting. " \
|
|
169
|
+
"Pick a different name or load the existing one with skill(name: \"#{skill_name}\")."
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# ---- load (unchanged) -------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def load_body(skill, skill_name)
|
|
175
|
+
body = "Skill '#{skill_name}' loaded:\n\n#{skill.content}"
|
|
176
|
+
body << linked_files_hint(skill, skill_name) unless skill.linked_files.empty?
|
|
177
|
+
announce_loaded(skill_name)
|
|
178
|
+
body
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def announce_loaded(skill_name)
|
|
182
|
+
Metrics.counter(:skills_loaded_total).increment
|
|
183
|
+
Rubino.active_event_bus&.emit(
|
|
184
|
+
Interaction::Events::SKILL_LOADED,
|
|
185
|
+
name: skill_name
|
|
186
|
+
)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def linked_files_hint(skill, skill_name)
|
|
190
|
+
listing = skill.linked_files.map { |f| " - #{f}" }.join("\n")
|
|
191
|
+
"\n\nBundled files (load with skill(name: \"#{skill_name}\", file_path: \"...\")):\n#{listing}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def load_bundled_file(skill, skill_name, file_path)
|
|
195
|
+
contents = skill.read_file(file_path)
|
|
196
|
+
if contents
|
|
197
|
+
"Skill '#{skill_name}' file '#{file_path}':\n\n#{contents}"
|
|
198
|
+
else
|
|
199
|
+
available = skill.current_linked_files.join(", ")
|
|
200
|
+
"File '#{file_path}' not found in skill '#{skill_name}'. " \
|
|
201
|
+
"Available files: #{available.empty? ? "(none)" : available}"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def not_found(skill_name)
|
|
206
|
+
available = @registry.names.join(", ")
|
|
207
|
+
"Skill '#{skill_name}' not found. Available skills: #{available}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def disabled(skill_name)
|
|
211
|
+
"Skill '#{skill_name}' is disabled."
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Skills
|
|
5
|
+
# Persists per-skill enable/disable flags in the `skill_states` table.
|
|
6
|
+
#
|
|
7
|
+
# Default-enabled semantics: a skill with no row is treated as enabled,
|
|
8
|
+
# so #enabled? returns true for unknown names. Only an explicit #set with
|
|
9
|
+
# `enabled: false` disables a skill, and the choice survives restarts.
|
|
10
|
+
#
|
|
11
|
+
# Writes go through Sequel's `insert_conflict(target: :name)` which maps
|
|
12
|
+
# to SQLite's `INSERT ... ON CONFLICT(name) DO UPDATE` (UPSERT).
|
|
13
|
+
class StateRepository
|
|
14
|
+
def initialize(db: nil)
|
|
15
|
+
@db = db || Rubino.database.db
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def enabled?(name)
|
|
19
|
+
row = @db[:skill_states].where(name: name.to_s).first
|
|
20
|
+
return true if row.nil?
|
|
21
|
+
|
|
22
|
+
row[:enabled] == true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def set(name, enabled:)
|
|
26
|
+
now = Time.now.utc.iso8601
|
|
27
|
+
@db[:skill_states]
|
|
28
|
+
.insert_conflict(target: :name, update: { enabled: enabled, updated_at: now })
|
|
29
|
+
.insert(name: name.to_s, enabled: enabled, updated_at: now)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def all
|
|
33
|
+
@db[:skill_states].all.to_h { |row| [row[:name], row[:enabled] == true] }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Skills
|
|
5
|
+
# ONE enable/disable write path for every surface (#188): the HTTP API
|
|
6
|
+
# toggle (PUT /v1/skills/:name), the in-chat `/skills enable|disable`
|
|
7
|
+
# and the `rubino skills enable|disable` CLI verbs all validate against
|
|
8
|
+
# the registry and persist through the same StateRepository write —
|
|
9
|
+
# previously the API operation was the ONLY caller of StateRepository#set,
|
|
10
|
+
# so a CLI-only user literally could not disable a skill.
|
|
11
|
+
module Toggle
|
|
12
|
+
# Persists the enabled flag for +name+. Returns the registered Skill
|
|
13
|
+
# (state written), or nil when the name is unknown (nothing written) —
|
|
14
|
+
# the caller decides how to surface the miss (404 for the API, a
|
|
15
|
+
# lowercase ✗ line for CLI/chat).
|
|
16
|
+
def self.set(name, enabled:, registry: nil, state_repository: nil)
|
|
17
|
+
registry ||= Registry.new
|
|
18
|
+
skill = registry.find(name)
|
|
19
|
+
return nil unless skill
|
|
20
|
+
|
|
21
|
+
(state_repository || StateRepository.new).set(name, enabled: enabled)
|
|
22
|
+
skill
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|