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,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Documents
|
|
5
|
+
# Ordered registry of document converters. Mirrors Tools::Registry's shape.
|
|
6
|
+
# Each converter is a class exposing instance methods:
|
|
7
|
+
# accepts?(mime, path) -> Boolean # by MIME first, extension as tie-break
|
|
8
|
+
# available? -> Boolean # its optional gem is loadable (true for pure-ruby)
|
|
9
|
+
# convert(path) -> String # the Markdown
|
|
10
|
+
#
|
|
11
|
+
# Order matters: more specific converters come before the plain-text
|
|
12
|
+
# catch-all so a .json routes to Json, not Plain. The registry never offers
|
|
13
|
+
# a converter whose optional gem can't load (#available?), so the caller's
|
|
14
|
+
# fall-through to the shell-hint is exercised when, e.g., `roo` is absent.
|
|
15
|
+
module Registry
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
# Converter classes in priority order. Trivial pure-ruby converters are
|
|
19
|
+
# always available; gem-backed ones (Xlsx/Docx/Pptx/Pdf) gate on their
|
|
20
|
+
# optional gem via #available?. Plain is the last-resort text passthrough.
|
|
21
|
+
def converters
|
|
22
|
+
[
|
|
23
|
+
Converters::Csv,
|
|
24
|
+
Converters::Json,
|
|
25
|
+
Converters::Xml,
|
|
26
|
+
Converters::Html,
|
|
27
|
+
Converters::Xlsx,
|
|
28
|
+
Converters::Docx,
|
|
29
|
+
Converters::Pptx,
|
|
30
|
+
Converters::Pdf,
|
|
31
|
+
Converters::Plain
|
|
32
|
+
]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns an instance of the first converter that accepts the pair AND is
|
|
36
|
+
# available in-process, or nil.
|
|
37
|
+
def for(mime: nil, path: nil)
|
|
38
|
+
converters.each do |klass|
|
|
39
|
+
conv = klass.new
|
|
40
|
+
return conv if conv.available? && conv.accepts?(mime, path)
|
|
41
|
+
end
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The CORE format labels currently supported in-process (their gem is
|
|
46
|
+
# loadable). Drives the doctor / EnvironmentInspector advertising. Each
|
|
47
|
+
# entry is [label, available?]; pure-ruby formats are always available.
|
|
48
|
+
def capabilities
|
|
49
|
+
{
|
|
50
|
+
"plain/code" => Converters::Plain.new.available?,
|
|
51
|
+
"csv" => Converters::Csv.new.available?,
|
|
52
|
+
"json" => Converters::Json.new.available?,
|
|
53
|
+
"xml" => Converters::Xml.new.available?,
|
|
54
|
+
"html" => Converters::Html.new.available?,
|
|
55
|
+
"xlsx" => Converters::Xlsx.new.available?,
|
|
56
|
+
"docx" => Converters::Docx.new.available?,
|
|
57
|
+
"pptx" => Converters::Pptx.new.available?,
|
|
58
|
+
"pdf" => Converters::Pdf.new.available?
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Just the labels currently available, for a compact one-line advert.
|
|
63
|
+
def available_formats
|
|
64
|
+
capabilities.select { |_, ok| ok }.keys
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Documents
|
|
5
|
+
# The ONE Markdown table emitter shared by the csv and xlsx converters (the
|
|
6
|
+
# reuse seam the plan calls for). Takes an array of rows (each an array of
|
|
7
|
+
# cell values) and emits a GFM pipe table: the first row is the header, a
|
|
8
|
+
# `|---|` separator follows, then the body. Pipes and newlines inside cells
|
|
9
|
+
# are escaped so a cell value can't break the table grid. Rows are capped so
|
|
10
|
+
# a runaway spreadsheet can't emit a million-line table into context.
|
|
11
|
+
module Table
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Hard cap on emitted body rows; over the cap we truncate and note it.
|
|
15
|
+
MAX_ROWS = 1000
|
|
16
|
+
|
|
17
|
+
# rows: Array<Array> -- first row is the header. Returns a GFM table
|
|
18
|
+
# String, or "" when there are no rows.
|
|
19
|
+
def emit(rows, max_rows: MAX_ROWS)
|
|
20
|
+
rows = Array(rows).compact
|
|
21
|
+
return "" if rows.empty?
|
|
22
|
+
|
|
23
|
+
width = rows.map { |r| Array(r).length }.max
|
|
24
|
+
return "" if width.nil? || width.zero?
|
|
25
|
+
|
|
26
|
+
header = pad(rows.first, width)
|
|
27
|
+
body = rows.drop(1)
|
|
28
|
+
truncated = body.length > max_rows
|
|
29
|
+
body = body.first(max_rows) if truncated
|
|
30
|
+
|
|
31
|
+
lines = []
|
|
32
|
+
lines << row_line(header)
|
|
33
|
+
lines << separator(width)
|
|
34
|
+
body.each { |r| lines << row_line(pad(r, width)) }
|
|
35
|
+
out = lines.join("\n")
|
|
36
|
+
out += "\n\n_(#{rows.length - 1 - max_rows} more rows truncated)_" if truncated
|
|
37
|
+
out
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def pad(row, width)
|
|
41
|
+
cells = Array(row).map { |c| cell(c) }
|
|
42
|
+
cells.fill("", cells.length...width)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def row_line(cells)
|
|
46
|
+
"| #{cells.join(" | ")} |"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def separator(width)
|
|
50
|
+
"|#{(["---"] * width).join("|")}|"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Escapes a cell so pipes/newlines can't break the table. nil -> "".
|
|
54
|
+
def cell(value)
|
|
55
|
+
value.to_s
|
|
56
|
+
.gsub("\\", "\\\\\\\\")
|
|
57
|
+
.gsub("|", "\\|")
|
|
58
|
+
.gsub(/\r\n?|\n/, "<br>")
|
|
59
|
+
.strip
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
# In-repo document-to-Markdown conversion -- a focused reimplementation of
|
|
5
|
+
# markitdown's CORE converters in pure Ruby (issue #6). The public surface is
|
|
6
|
+
# a single entry point:
|
|
7
|
+
#
|
|
8
|
+
# Rubino::Documents.to_markdown(path, mime: nil) -> String | nil
|
|
9
|
+
#
|
|
10
|
+
# Architecture (mirrors markitdown): most converters extract structure via a
|
|
11
|
+
# mature MIT gem, shape it into an intermediate HTML string, and let ONE
|
|
12
|
+
# HTML->Markdown core (Documents::Html, built on kramdown which is already a
|
|
13
|
+
# rubino dependency) emit the final Markdown. csv/xlsx feed ONE Markdown table
|
|
14
|
+
# emitter (Documents::Table). The per-format converters are therefore thin.
|
|
15
|
+
#
|
|
16
|
+
# Extraction gems (roo, docx, pdf-reader, ruby_powerpoint) are OPTIONAL: each
|
|
17
|
+
# converter `require`s its gem lazily inside a begin/rescue LoadError and a
|
|
18
|
+
# converter that can't load its gem simply reports itself unavailable. The
|
|
19
|
+
# module MUST load and run with NONE of the optional gems installed -- callers
|
|
20
|
+
# then fall back to the existing shell-extraction hint. There is never an
|
|
21
|
+
# external process and never a hard runtime dependency. That is the whole
|
|
22
|
+
# point: the original concern was "markitdown isn't installed".
|
|
23
|
+
module Documents
|
|
24
|
+
module_function
|
|
25
|
+
|
|
26
|
+
# Converts the file at +path+ to Markdown, picking the first registered
|
|
27
|
+
# converter that accepts the (mime, path) pair and whose optional gem is
|
|
28
|
+
# loadable. Returns the Markdown String, or nil when no converter can handle
|
|
29
|
+
# the file (unknown format, or the format's optional gem isn't installed, or
|
|
30
|
+
# extraction produced nothing). Never raises -- a converter failure degrades
|
|
31
|
+
# to nil so the caller emits the actionable shell-hint.
|
|
32
|
+
def to_markdown(path, mime: nil)
|
|
33
|
+
converter = Registry.for(mime: mime, path: path)
|
|
34
|
+
return nil unless converter
|
|
35
|
+
|
|
36
|
+
out = converter.convert(path)
|
|
37
|
+
out = out.to_s
|
|
38
|
+
out.strip.empty? ? nil : out
|
|
39
|
+
rescue LoadError, StandardError
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# True when at least one converter for the (mime, path) pair is available
|
|
44
|
+
# in-process (its optional gem, if any, is loadable). Drives the preamble /
|
|
45
|
+
# environment / doctor advertising without attempting a conversion.
|
|
46
|
+
def supported?(mime: nil, path: nil)
|
|
47
|
+
!Registry.for(mime: mime, path: path).nil?
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
# HTTP-boundary error hierarchy. Each class maps to a single HTTP status
|
|
5
|
+
# used by the API layer to translate exceptions to responses.
|
|
6
|
+
#
|
|
7
|
+
# Rubino::Error — base class (defined in lib/rubino.rb)
|
|
8
|
+
# NotFoundError(resource, id) — 404
|
|
9
|
+
# ValidationError(message, details:) — 422
|
|
10
|
+
# UnauthorizedError(message) — 401
|
|
11
|
+
# ConflictError(message) — 409
|
|
12
|
+
# UpstreamError(message, service:) — 502
|
|
13
|
+
#
|
|
14
|
+
# All message-first classes accept `raise Class, "msg"` (Ruby's idiomatic
|
|
15
|
+
# form) without losing data. NotFoundError keeps its (resource, id) shape
|
|
16
|
+
# because the message format depends on both values; always use
|
|
17
|
+
# +raise NotFoundError.new("session", id)+, not +raise NotFoundError, "..."+.
|
|
18
|
+
#
|
|
19
|
+
# Domain errors (ConfigurationError, DatabaseError, SessionError, ToolError,
|
|
20
|
+
# CompactionError, JobError) also subclass Error and live in lib/rubino.rb.
|
|
21
|
+
|
|
22
|
+
# Resource not found. Maps to 404.
|
|
23
|
+
#
|
|
24
|
+
# @param resource [String, Symbol] resource type (e.g. "Session", :run)
|
|
25
|
+
# @param id [String, nil] identifier; when nil only the resource name is shown
|
|
26
|
+
#
|
|
27
|
+
# Footgun: `raise NotFoundError, "foo"` skips this initializer (Ruby passes
|
|
28
|
+
# the string straight to StandardError#initialize), so @resource/@id stay nil.
|
|
29
|
+
# Always use `raise NotFoundError.new("Session", id)` to capture them.
|
|
30
|
+
class NotFoundError < Error
|
|
31
|
+
def initialize(resource, id = nil)
|
|
32
|
+
msg = id ? "#{resource} not found: #{id}" : "#{resource} not found"
|
|
33
|
+
super(msg)
|
|
34
|
+
@resource = resource
|
|
35
|
+
@id = id
|
|
36
|
+
end
|
|
37
|
+
attr_reader :resource, :id
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Request body or params failed validation. Maps to 422.
|
|
41
|
+
class ValidationError < Error
|
|
42
|
+
def initialize(message = "validation failed", details: {})
|
|
43
|
+
super(message)
|
|
44
|
+
@details = details
|
|
45
|
+
end
|
|
46
|
+
attr_reader :details
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Missing or invalid credentials. Maps to 401.
|
|
50
|
+
class UnauthorizedError < Error
|
|
51
|
+
def initialize(message = "unauthorized")
|
|
52
|
+
super
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# State conflict (duplicate, illegal transition). Maps to 409.
|
|
57
|
+
class ConflictError < Error
|
|
58
|
+
def initialize(message = "conflict")
|
|
59
|
+
super
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# User interrupted an in-progress LLM turn (Esc / Ctrl+C in the chat TUI).
|
|
64
|
+
# Caught by the Loop/Lifecycle so partial content can still be persisted
|
|
65
|
+
# and the UI can return to a ready state cleanly.
|
|
66
|
+
class Interrupted < Error
|
|
67
|
+
def initialize(message = "interrupted by user")
|
|
68
|
+
super
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Request body exceeded the configured byte cap (JSON or multipart upload).
|
|
73
|
+
# Maps to 413. Details may carry +limit_bytes+ so clients can adapt.
|
|
74
|
+
class PayloadTooLargeError < Error
|
|
75
|
+
def initialize(message = "payload too large", details: {})
|
|
76
|
+
super(message)
|
|
77
|
+
@details = details
|
|
78
|
+
end
|
|
79
|
+
attr_reader :details
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Upstream dependency failed (LLM provider, OAuth provider). Maps to 502.
|
|
83
|
+
# Message-first so +raise UpstreamError, "timeout"+ works; pass +service:+
|
|
84
|
+
# to tag the failing dependency (it gets prefixed onto the message).
|
|
85
|
+
class UpstreamError < Error
|
|
86
|
+
def initialize(message = "upstream error", service: nil)
|
|
87
|
+
super(service ? "#{service}: #{message}" : message)
|
|
88
|
+
@service = service
|
|
89
|
+
end
|
|
90
|
+
attr_reader :service
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The LLM streaming response was cut before a clean completion: upstream closed
|
|
94
|
+
# the SSE connection without a terminal signal (no finish_reason / no [DONE] /
|
|
95
|
+
# null usage), leaving only a buffered partial with no tool call. Raised by the
|
|
96
|
+
# Loop so a truncated turn fails honestly (run.failed) instead of being reported
|
|
97
|
+
# as a successful "completed" turn carrying empty/partial output. Common trigger:
|
|
98
|
+
# a provider stream idle-timeout during a long time-to-first-token on a very
|
|
99
|
+
# large context. Maps to 502 (subclass of UpstreamError).
|
|
100
|
+
class StreamInterruptedError < UpstreamError
|
|
101
|
+
def initialize(message = "stream ended before completion", service: "llm")
|
|
102
|
+
super
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The model returned a degenerate turn — no text AND no tool calls — that
|
|
107
|
+
# survived the Loop's in-turn retries. Mirrors the reference treating an
|
|
108
|
+
# empty/invalid response as retryable-then-terminal (such a run is
|
|
109
|
+
# marked `failed: True`, not `completed`). Raised by Agent::Loop so
|
|
110
|
+
# the run is marked failed honestly instead of being reported as a successful
|
|
111
|
+
# "completed" turn carrying empty output (the silent completed-but-empty bug,
|
|
112
|
+
# observed on MiniMax-M2.7 / api.minimax.io/anthropic). Maps to 502.
|
|
113
|
+
class EmptyModelResponseError < UpstreamError
|
|
114
|
+
def initialize(message = "model returned an empty response (no text, no tool calls)",
|
|
115
|
+
service: "llm")
|
|
116
|
+
super
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
|
|
7
|
+
module Rubino
|
|
8
|
+
module Files
|
|
9
|
+
# Sandboxed filesystem access for the agent. Every path coming in from
|
|
10
|
+
# the HTTP API (read, upload, etc.) must be resolved through #resolve so
|
|
11
|
+
# the result is guaranteed to live under @root.
|
|
12
|
+
#
|
|
13
|
+
# Root defaults to config.paths_home (the agent home); uploads are
|
|
14
|
+
# written under `<root>/uploads/`. The root is overridable in tests.
|
|
15
|
+
#
|
|
16
|
+
# Path-traversal defense:
|
|
17
|
+
# - Pathname#+ does not normalize: `Pathname.new("/a") + "/b"` returns
|
|
18
|
+
# `/b`, so an attacker-supplied absolute path would silently escape.
|
|
19
|
+
# - We therefore call #expand_path on the joined path and then verify it
|
|
20
|
+
# begins with `@root + File::SEPARATOR` (or equals @root). If not,
|
|
21
|
+
# we raise Workspace::PathTraversal (a ValidationError subclass).
|
|
22
|
+
class Workspace
|
|
23
|
+
class PathTraversal < ::Rubino::ValidationError
|
|
24
|
+
def initialize(path)
|
|
25
|
+
super("path escapes workspace: #{path}")
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(root: nil)
|
|
30
|
+
path = root || ::Rubino.configuration.paths_home
|
|
31
|
+
expanded = File.expand_path(path)
|
|
32
|
+
FileUtils.mkdir_p(expanded)
|
|
33
|
+
# Resolve symlinks (macOS' /tmp → /private/tmp is the usual offender)
|
|
34
|
+
# so #resolve compares apples to apples. Tools and AttachFileTool
|
|
35
|
+
# both call File.expand_path on their inputs, which follows OS
|
|
36
|
+
# symlinks; storing the raw configured root here would then make
|
|
37
|
+
# every absolute path under /tmp look like an escape, even though
|
|
38
|
+
# it really points inside the sandbox.
|
|
39
|
+
@root = Pathname.new(File.realpath(expanded))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
attr_reader :root
|
|
43
|
+
|
|
44
|
+
# Resolves a relative path against the workspace root.
|
|
45
|
+
# Raises PathTraversal if the resolved path escapes the root.
|
|
46
|
+
def resolve(relative_path)
|
|
47
|
+
candidate = (@root + relative_path).expand_path
|
|
48
|
+
# If the candidate exists on disk, run it through realpath too so
|
|
49
|
+
# symlink components in the leading path don't make us reject a
|
|
50
|
+
# path that physically lives under @root. For paths that don't
|
|
51
|
+
# exist yet (the upload-create case) we keep the expand_path form
|
|
52
|
+
# — File.realpath would raise on a missing file.
|
|
53
|
+
candidate = Pathname.new(File.realpath(candidate.to_s)) if candidate.exist?
|
|
54
|
+
|
|
55
|
+
unless candidate.to_s.start_with?(@root.to_s + File::SEPARATOR) || candidate == @root
|
|
56
|
+
raise PathTraversal, relative_path
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
candidate
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Reads a file from the sandbox.
|
|
63
|
+
#
|
|
64
|
+
# @param relative_path [String] path relative to the workspace root
|
|
65
|
+
# @return [String] binary contents of the file
|
|
66
|
+
# @raise [Workspace::PathTraversal] if the path escapes the sandbox
|
|
67
|
+
# @raise [Rubino::NotFoundError] if no regular file exists at the path
|
|
68
|
+
def read(relative_path)
|
|
69
|
+
path = resolve(relative_path)
|
|
70
|
+
raise ::Rubino::NotFoundError.new("file", relative_path) unless path.file?
|
|
71
|
+
|
|
72
|
+
path.binread
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Stores an uploaded file under `uploads/<uuid>-<basename>`.
|
|
76
|
+
# The original filename is reduced to its basename before joining, so
|
|
77
|
+
# callers cannot influence the destination directory.
|
|
78
|
+
#
|
|
79
|
+
# @param filename [String] client-supplied name (basename only is kept)
|
|
80
|
+
# @param io [IO] readable stream containing the upload body
|
|
81
|
+
# @return [Hash] descriptor with keys :id, :filename, :size, :path
|
|
82
|
+
def upload(filename:, io:)
|
|
83
|
+
uploads_dir = @root + "uploads"
|
|
84
|
+
FileUtils.mkdir_p(uploads_dir)
|
|
85
|
+
safe_name = File.basename(filename.to_s)
|
|
86
|
+
id = SecureRandom.uuid
|
|
87
|
+
target = uploads_dir + "#{id}-#{safe_name}"
|
|
88
|
+
size = IO.copy_stream(io, target.to_s)
|
|
89
|
+
{ id: id, filename: safe_name, size: size, path: target.to_s }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Interaction
|
|
5
|
+
# Thread-safe cooperative cancellation flag passed through the interaction
|
|
6
|
+
# stack (Runner -> Lifecycle -> Loop -> LLM adapter). The chat TUI flips
|
|
7
|
+
# it on Esc / second Ctrl+C, and the LLM stream callback raises
|
|
8
|
+
# Rubino::Interrupted at the next chunk boundary so the turn aborts
|
|
9
|
+
# without leaking the worker thread or losing buffered output.
|
|
10
|
+
#
|
|
11
|
+
# Cancellation is one-shot: once cancelled, it stays cancelled. Build a
|
|
12
|
+
# fresh token per turn rather than reusing across turns.
|
|
13
|
+
#
|
|
14
|
+
# No Mutex on purpose. The flag is written exactly once (false -> true,
|
|
15
|
+
# never back) and only ever read otherwise — a single-writer, monotonic
|
|
16
|
+
# boolean. Under MRI's GVL a lone ivar read/write is atomic, so no lock
|
|
17
|
+
# is needed for correctness. Critically, #cancel! runs from a SIGINT
|
|
18
|
+
# +Signal.trap+ block, and +Mutex#lock+ is forbidden in a trap context
|
|
19
|
+
# (Ruby bug #14222: "can't be called from trap context"). A mutex here
|
|
20
|
+
# made the chat trap raise ThreadError, the flag never flipped, and the
|
|
21
|
+
# turn ran on. Keep this lock-free and trap-safe.
|
|
22
|
+
class CancelToken
|
|
23
|
+
def initialize
|
|
24
|
+
@cancelled = false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def cancel!
|
|
28
|
+
@cancelled = true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def cancelled?
|
|
32
|
+
@cancelled
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Raises Interrupted if the token has been cancelled. Used as a poll
|
|
36
|
+
# point inside hot loops (per-chunk in streams, per-iteration in the
|
|
37
|
+
# agent loop).
|
|
38
|
+
def check!
|
|
39
|
+
raise Rubino::Interrupted if cancelled?
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "open3"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module Interaction
|
|
8
|
+
# Grabs an image from the system clipboard and writes it to a temp PNG so it
|
|
9
|
+
# can be attached to a turn's image_paths (the native vision slot). Mirrors
|
|
10
|
+
# Claude Code's Cmd+V image paste from the terminal.
|
|
11
|
+
#
|
|
12
|
+
# Platform tools, best-effort and in priority order:
|
|
13
|
+
# - macOS : `pngpaste` (brew install pngpaste)
|
|
14
|
+
# - Wayland: `wl-paste` (wl-clipboard)
|
|
15
|
+
# - X11 : `xclip`
|
|
16
|
+
#
|
|
17
|
+
# Returns the temp file path on success, or nil when no tool is available or
|
|
18
|
+
# the clipboard holds no image. #unavailable_reason explains a nil so the CLI
|
|
19
|
+
# can show an actionable hint instead of a silent no-op.
|
|
20
|
+
module ClipboardImage
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# Ordered [tool, argv-builder] candidates. The builder takes the dest path
|
|
24
|
+
# and returns the argv that writes a PNG of the clipboard image to it.
|
|
25
|
+
def commands(dest)
|
|
26
|
+
case RbConfig::CONFIG["host_os"]
|
|
27
|
+
when /darwin/
|
|
28
|
+
[["pngpaste", [dest]]]
|
|
29
|
+
when /linux/
|
|
30
|
+
[
|
|
31
|
+
["wl-paste", ["-t", "image/png", "--no-newline"]], # writes to stdout
|
|
32
|
+
["xclip", ["-selection", "clipboard", "-t", "image/png", "-o"]]
|
|
33
|
+
]
|
|
34
|
+
else
|
|
35
|
+
[]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Saves the clipboard image to a temp PNG and returns its path, or nil.
|
|
40
|
+
def save_to_tempfile
|
|
41
|
+
dest = File.join(Dir.tmpdir, "rubino_clip_#{Process.pid}_#{rand(1_000_000)}.png")
|
|
42
|
+
capture(dest) ? dest : nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Runs the first available tool. macOS pngpaste writes the file directly;
|
|
46
|
+
# the Linux tools write PNG bytes to stdout which we redirect to +dest+.
|
|
47
|
+
def capture(dest)
|
|
48
|
+
commands(dest).each do |tool, args|
|
|
49
|
+
next unless which(tool)
|
|
50
|
+
|
|
51
|
+
if tool == "pngpaste"
|
|
52
|
+
_out, = Open3.capture2e(tool, *args)
|
|
53
|
+
else
|
|
54
|
+
out, status = Open3.capture2(tool, *args)
|
|
55
|
+
File.binwrite(dest, out) if status.success? && !out.empty?
|
|
56
|
+
end
|
|
57
|
+
return true if File.file?(dest) && File.size(dest).positive?
|
|
58
|
+
end
|
|
59
|
+
false
|
|
60
|
+
rescue StandardError
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Human-readable reason a paste produced nothing, for the CLI hint.
|
|
65
|
+
def unavailable_reason
|
|
66
|
+
case RbConfig::CONFIG["host_os"]
|
|
67
|
+
when /darwin/
|
|
68
|
+
"no image on the clipboard, or `pngpaste` isn't installed (brew install pngpaste)."
|
|
69
|
+
when /linux/
|
|
70
|
+
"no image on the clipboard, or neither `wl-paste` nor `xclip` is installed."
|
|
71
|
+
else
|
|
72
|
+
"clipboard image paste isn't supported on this platform."
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def which(tool)
|
|
77
|
+
ENV["PATH"].to_s.split(File::PATH_SEPARATOR).any? do |dir|
|
|
78
|
+
path = File.join(dir, tool)
|
|
79
|
+
File.executable?(path) && !File.directory?(path)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Interaction
|
|
5
|
+
# Simple pub/sub event bus for decoupling core logic from UI.
|
|
6
|
+
# Core components emit events; UI adapters and other listeners subscribe.
|
|
7
|
+
#
|
|
8
|
+
# Thread-safety: subscriptions are mutated under a mutex, and `emit`
|
|
9
|
+
# snapshots the listener list under the lock then invokes listeners
|
|
10
|
+
# OUTSIDE the lock. This keeps concurrent `on`/`off` (e.g. a parent run's
|
|
11
|
+
# `recorder.detach!` racing a background subagent thread emitting
|
|
12
|
+
# SUBAGENT_COMPLETED onto the same bus — #136) from mutating the hash
|
|
13
|
+
# mid-iteration, while still allowing a listener to itself emit/subscribe
|
|
14
|
+
# without deadlocking.
|
|
15
|
+
class EventBus
|
|
16
|
+
def initialize
|
|
17
|
+
@listeners = Hash.new { |h, k| h[k] = [] }
|
|
18
|
+
@mutex = Mutex.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Subscribe to an event type with a callable or block
|
|
22
|
+
def on(event_type, &block)
|
|
23
|
+
@mutex.synchronize { @listeners[event_type.to_sym] << block }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Emit an event to all registered listeners
|
|
27
|
+
def emit(event_type, **payload)
|
|
28
|
+
listeners = @mutex.synchronize { @listeners[event_type.to_sym].dup }
|
|
29
|
+
listeners.each { |listener| listener.call(payload) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Remove all listeners for a given event type
|
|
33
|
+
def off(event_type)
|
|
34
|
+
@mutex.synchronize { @listeners.delete(event_type.to_sym) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Remove all listeners
|
|
38
|
+
def clear!
|
|
39
|
+
@mutex.synchronize { @listeners.clear }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Returns the count of listeners for a given event type
|
|
43
|
+
def listener_count(event_type)
|
|
44
|
+
@mutex.synchronize { @listeners[event_type.to_sym].size }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Interaction
|
|
5
|
+
# Defines all event types used in the system.
|
|
6
|
+
# Acts as documentation and provides constants for event names.
|
|
7
|
+
module Events
|
|
8
|
+
# Interaction lifecycle events
|
|
9
|
+
INTERACTION_STARTED = :interaction_started
|
|
10
|
+
INTERACTION_FINISHED = :interaction_finished
|
|
11
|
+
INTERACTION_FAILED = :interaction_failed
|
|
12
|
+
# Mid-turn steering: the user typed while the agent was working and the
|
|
13
|
+
# loop picked the text up at an iteration boundary, injecting it as a
|
|
14
|
+
# user message into the in-flight turn (Codex "Enter injects into the
|
|
15
|
+
# current turn"). Payload: { text:, iteration: }.
|
|
16
|
+
INPUT_INJECTED = :input_injected
|
|
17
|
+
|
|
18
|
+
# Status change events
|
|
19
|
+
STATUS_CHANGED = :status_changed
|
|
20
|
+
|
|
21
|
+
# Session events
|
|
22
|
+
SESSION_LOADED = :session_loaded
|
|
23
|
+
SESSION_CREATED = :session_created
|
|
24
|
+
SESSION_PERSISTED = :session_persisted
|
|
25
|
+
|
|
26
|
+
# Memory events
|
|
27
|
+
MEMORY_LOADED = :memory_loaded
|
|
28
|
+
MEMORY_EXTRACTED = :memory_extracted
|
|
29
|
+
MEMORY_FLUSHED = :memory_flushed
|
|
30
|
+
|
|
31
|
+
# Context events
|
|
32
|
+
PROMPT_ASSEMBLED = :prompt_assembled
|
|
33
|
+
CONTEXT_BUDGET_CHECKED = :context_budget_checked
|
|
34
|
+
|
|
35
|
+
# Compression events
|
|
36
|
+
COMPRESSION_STARTED = :compression_started
|
|
37
|
+
COMPRESSION_FINISHED = :compression_finished
|
|
38
|
+
|
|
39
|
+
# LLM events
|
|
40
|
+
MODEL_CALL_STARTED = :model_call_started
|
|
41
|
+
MODEL_CALL_FINISHED = :model_call_finished
|
|
42
|
+
MODEL_STREAM = :model_stream
|
|
43
|
+
# End of one assistant message (content block). Streamed content deltas
|
|
44
|
+
# carry a +message_id+; this marks that block complete so a consumer can
|
|
45
|
+
# group the deltas that belong together instead of splitting them around
|
|
46
|
+
# tool calls that interleave mid-stream. Mirrors Anthropic's
|
|
47
|
+
# content_block_stop / the AI SDK text-end{id}.
|
|
48
|
+
MESSAGE_COMPLETED = :message_completed
|
|
49
|
+
|
|
50
|
+
# Tool events
|
|
51
|
+
TOOL_STARTED = :tool_started
|
|
52
|
+
# Incremental progress from a long-running tool (e.g. summarize_file's
|
|
53
|
+
# per-chunk "summarizing chunk N/M" or shell stdout lines). Emitted from
|
|
54
|
+
# the tool's stream_chunk callback so a tool that runs for minutes
|
|
55
|
+
# without finishing keeps the API event stream alive — the SSE idle
|
|
56
|
+
# watchdog only fires when NOTHING flows, so a genuinely hung run is
|
|
57
|
+
# still caught while a busy-but-silent one heartbeats. Payload:
|
|
58
|
+
# { name:, chunk: }.
|
|
59
|
+
TOOL_PROGRESS = :tool_progress
|
|
60
|
+
TOOL_FINISHED = :tool_finished
|
|
61
|
+
TOOL_APPROVAL_REQUESTED = :tool_approval_requested
|
|
62
|
+
TOOL_APPROVAL_GRANTED = :tool_approval_granted
|
|
63
|
+
TOOL_APPROVAL_DENIED = :tool_approval_denied
|
|
64
|
+
|
|
65
|
+
# Job events
|
|
66
|
+
JOB_ENQUEUED = :job_enqueued
|
|
67
|
+
JOB_STARTED = :job_started
|
|
68
|
+
JOB_FINISHED = :job_finished
|
|
69
|
+
JOB_FAILED = :job_failed
|
|
70
|
+
JOB_RETRYING = :job_retrying
|
|
71
|
+
|
|
72
|
+
# Background subagent (the `task` tool run in the background, the default).
|
|
73
|
+
# SPAWNED when a backgrounded subagent starts (payload: { task_id:,
|
|
74
|
+
# subagent:, prompt: }); COMPLETED/FAILED when it finishes (payload:
|
|
75
|
+
# { task_id:, subagent:, status:, output:|error: }). These let the CLI
|
|
76
|
+
# surface a completion line and the web UI show in-flight subagents —
|
|
77
|
+
# parity with how background-shell activity surfaces.
|
|
78
|
+
SUBAGENT_SPAWNED = :subagent_spawned
|
|
79
|
+
SUBAGENT_COMPLETED = :subagent_completed
|
|
80
|
+
SUBAGENT_FAILED = :subagent_failed
|
|
81
|
+
|
|
82
|
+
# Skill events
|
|
83
|
+
# Emitted when the `skill` tool successfully loads a skill's body into
|
|
84
|
+
# context (the level-2 "Skill 'X' loaded" path), so skill usage is a
|
|
85
|
+
# first-class signal for the recorder/SSE/metrics — parity with how
|
|
86
|
+
# TOOL_STARTED/SUBAGENT_* surface lifecycle. Payload: { name: } — the run
|
|
87
|
+
# association is stamped by the Recorder (run_id), like every other event.
|
|
88
|
+
SKILL_LOADED = :skill_loaded
|
|
89
|
+
|
|
90
|
+
# Emitted when a skill is created inline via skill(action: "create") or by
|
|
91
|
+
# the post-turn distill job. Payload: { name:, file_path: }.
|
|
92
|
+
SKILL_CREATED = :skill_created
|
|
93
|
+
|
|
94
|
+
# Artifact events
|
|
95
|
+
# Fired by tools that produce a downloadable user-facing file
|
|
96
|
+
# (currently AttachFileTool). Payload: { path:, filename:,
|
|
97
|
+
# content_type:, byte_size: }.
|
|
98
|
+
ARTIFACT_CREATED = :artifact_created
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|