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,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module UI
|
|
8
|
+
# Nested UI adapter for a running subagent (the `task` tool).
|
|
9
|
+
#
|
|
10
|
+
# While the parent loop delegates to a subagent, the child runs its own
|
|
11
|
+
# isolated Agent::Runner. By default that child is wired with UI::Null, so
|
|
12
|
+
# its activity is invisible. This adapter makes the child's TOOL ACTIVITY
|
|
13
|
+
# visible INLINE — compact rows indented under the parent's
|
|
14
|
+
# "● delegated → X" delegation boundary, in a per-subagent color — so the
|
|
15
|
+
# user can watch what the subagent is doing live.
|
|
16
|
+
#
|
|
17
|
+
# DISPLAY ONLY. This adapter writes to $stdout (which, during a parent turn,
|
|
18
|
+
# is the composer proxy → committed above the bottom composer like every
|
|
19
|
+
# other timeline row). It never touches the parent loop's `messages` or the
|
|
20
|
+
# parent recorder: the result-only contract is unchanged. The parent model
|
|
21
|
+
# still receives ONLY the subagent's final result (the `task` tool result).
|
|
22
|
+
#
|
|
23
|
+
# COLLAPSED-CARD MODE (Variant A — kills the flood, #124): instead of
|
|
24
|
+
# writing one $stdout row per child tool call (which buried the parent
|
|
25
|
+
# prompt), tool_started/tool_finished now feed the BackgroundTasks REGISTRY
|
|
26
|
+
# entry for this run (last_activity + a tool counter + a bounded recent-ring)
|
|
27
|
+
# and ask the parent UI to repaint its collapsed live CARD. The card shows a
|
|
28
|
+
# single in-place line per subagent (`▸ sa_… · explore · running · N tools ·
|
|
29
|
+
# Ns · <last_activity>`) that updates without scrolling — see UI::CLI
|
|
30
|
+
# #set_subagent_cards / UI::SubagentCards. The /agents <id> drill-in tails the
|
|
31
|
+
# same registry ring for the live recent: list (#71).
|
|
32
|
+
#
|
|
33
|
+
# The view is wired with the entry id at construction (TaskTool builds it per
|
|
34
|
+
# background run). With no id (legacy/foreground synchronous path, tests) it
|
|
35
|
+
# falls back to the OLD inline rows so the synchronous delegation surface and
|
|
36
|
+
# its specs are unchanged.
|
|
37
|
+
#
|
|
38
|
+
# Inline (legacy) format, 2-space extra indent under the delegation row:
|
|
39
|
+
# ` ⟂ explore · read lib/foo.rb`
|
|
40
|
+
# ` ⟂ explore · ✓ grep · 3 matches`
|
|
41
|
+
#
|
|
42
|
+
# Noise control:
|
|
43
|
+
# - stream / stream_end / assistant_text / thinking_started are SUPPRESSED
|
|
44
|
+
# (the subagent's prose isn't shown — only its steps and the final
|
|
45
|
+
# result, which the parent already prints as "✓ X: result");
|
|
46
|
+
# - note / status / info render as dim nested lines (low-noise) ONLY in the
|
|
47
|
+
# legacy inline path; in card mode they fold into the registry too;
|
|
48
|
+
# - confirm: in card mode it does NOT auto-deny — it surfaces the approval
|
|
49
|
+
# on the card and parks the child on a per-entry gate (Option 2; wired by
|
|
50
|
+
# TaskTool). With no approval handler (legacy/foreground) it auto-DENIES
|
|
51
|
+
# so a subagent never blocks on a prompt no one can answer.
|
|
52
|
+
class SubagentView < Base
|
|
53
|
+
# Deterministic per-subagent palette. Chosen by hashing the agent name so
|
|
54
|
+
# the same subagent always renders in the same color (no Math.random),
|
|
55
|
+
# and concurrent/sequential delegations to different subagents stay
|
|
56
|
+
# visually distinct. All names are valid Pastel foreground colors.
|
|
57
|
+
PALETTE = %i[cyan magenta blue yellow green bright_cyan].freeze
|
|
58
|
+
|
|
59
|
+
# Nested-row indent: 2 spaces beyond the CLI's own 2-space body indent so
|
|
60
|
+
# the subagent's steps read as nested under the "● delegated → X" row.
|
|
61
|
+
INDENT = " "
|
|
62
|
+
|
|
63
|
+
# Glyph prefixing every subagent activity row.
|
|
64
|
+
GLYPH = "⟂"
|
|
65
|
+
|
|
66
|
+
# @param entry_id [String, nil] the BackgroundTasks entry this view feeds
|
|
67
|
+
# in card mode. nil ⇒ legacy inline-row mode (synchronous/foreground path).
|
|
68
|
+
# @param parent_ui [UI::CLI, nil] the parent CLI whose live region hosts the
|
|
69
|
+
# collapsed card; #set_subagent_cards repaints it. Captured at spawn on the
|
|
70
|
+
# parent thread (the child thread has no access to the parent's UI).
|
|
71
|
+
# @param approve [#call, nil] in card mode, the approval handler TaskTool
|
|
72
|
+
# wires: called with (question, scope:, command:) and returns the boolean
|
|
73
|
+
# decision. nil ⇒ #confirm auto-denies (legacy behavior).
|
|
74
|
+
def initialize(agent_name:, out: $stdout, pastel: Pastel.new,
|
|
75
|
+
entry_id: nil, parent_ui: nil, approve: nil)
|
|
76
|
+
@agent_name = agent_name.to_s
|
|
77
|
+
@out = out
|
|
78
|
+
@pastel = pastel
|
|
79
|
+
@color = PALETTE[color_index(@agent_name)]
|
|
80
|
+
@entry_id = entry_id
|
|
81
|
+
@parent_ui = parent_ui
|
|
82
|
+
@approve = approve
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The color this view paints its rows in (exposed for tests).
|
|
86
|
+
attr_reader :color
|
|
87
|
+
|
|
88
|
+
# True when this view feeds a registry entry (collapsed-card mode) rather
|
|
89
|
+
# than flooding $stdout with per-tool rows (legacy inline mode).
|
|
90
|
+
def card_mode?
|
|
91
|
+
!@entry_id.nil?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# --- Rendered: tool activity (the "what it's doing") -------------------
|
|
95
|
+
|
|
96
|
+
# Card mode: record the tool start on the registry entry (last_activity +
|
|
97
|
+
# tool counter) and repaint the parent's collapsed card — NO $stdout row, so
|
|
98
|
+
# a read-heavy child never floods the parent terminal (#124). Legacy mode:
|
|
99
|
+
# the old inline ` ⟂ explore · read lib/foo.rb` row.
|
|
100
|
+
def tool_started(name, arguments: nil, at: nil)
|
|
101
|
+
hint = args_hint(arguments)
|
|
102
|
+
if card_mode?
|
|
103
|
+
activity = hint ? "#{name} #{hint}" : name.to_s
|
|
104
|
+
Tools::BackgroundTasks.instance.record_tool_started(@entry_id, activity)
|
|
105
|
+
repaint_cards
|
|
106
|
+
else
|
|
107
|
+
body = hint ? "#{name} #{hint}" : name.to_s
|
|
108
|
+
row(body)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Card mode: append the terse finish line to the entry's recent-ring (which
|
|
113
|
+
# the /agents drill-in tails) and repaint. Legacy mode: the old inline row.
|
|
114
|
+
def tool_finished(name, result: nil)
|
|
115
|
+
failed = result.respond_to?(:success?) && !result.success?
|
|
116
|
+
icon = failed ? "✗" : "✓"
|
|
117
|
+
suffix = result_metric(result)
|
|
118
|
+
body = suffix ? "#{icon} #{name} · #{suffix}" : "#{icon} #{name}"
|
|
119
|
+
if card_mode?
|
|
120
|
+
Tools::BackgroundTasks.instance.record_tool_finished(@entry_id, body)
|
|
121
|
+
repaint_cards
|
|
122
|
+
else
|
|
123
|
+
row(body)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# tool_body / tool_chunk: the child's tool previews/streamed chunks. Kept
|
|
128
|
+
# quiet to stay low-noise — the start/finish rows already say what ran.
|
|
129
|
+
def tool_body(_text, kind: :plain); end
|
|
130
|
+
def tool_chunk(_name, _chunk); end
|
|
131
|
+
|
|
132
|
+
# --- Suppressed: the child's prose / token stream ---------------------
|
|
133
|
+
|
|
134
|
+
def stream(_chunk); end
|
|
135
|
+
def stream_end; end
|
|
136
|
+
def assistant_text(_text); end
|
|
137
|
+
def body(_text); end
|
|
138
|
+
def thinking_started; end
|
|
139
|
+
def replay_user_input(_text, at: nil); end
|
|
140
|
+
def table(headers:, rows:); end
|
|
141
|
+
|
|
142
|
+
# --- Low-noise: dim nested annotations -------------------------------
|
|
143
|
+
|
|
144
|
+
# In card mode these fold away (the card is the only surface); in legacy
|
|
145
|
+
# inline mode they keep their dim nested rows.
|
|
146
|
+
def note(text) = card_mode? ? nil : dim_row(text)
|
|
147
|
+
def status(text) = card_mode? ? nil : dim_row(text)
|
|
148
|
+
def info(text) = card_mode? ? nil : dim_row(text)
|
|
149
|
+
|
|
150
|
+
def success(message) = card_mode? ? nil : row("✓ #{message}")
|
|
151
|
+
def warning(message) = card_mode? ? nil : row("⚠ #{message}")
|
|
152
|
+
def error(message) = card_mode? ? nil : row("✗ #{message}")
|
|
153
|
+
|
|
154
|
+
# --- Suppressed lifecycle chrome ------------------------------------
|
|
155
|
+
|
|
156
|
+
def separator; end
|
|
157
|
+
def blank_line; end
|
|
158
|
+
def compression_started(at: nil); end
|
|
159
|
+
def compression_finished(_metadata, at: nil); end
|
|
160
|
+
def job_enqueued(_type); end
|
|
161
|
+
def job_started(_type); end
|
|
162
|
+
def job_finished(_type); end
|
|
163
|
+
def mode_changed(_name, previous: nil); end
|
|
164
|
+
def box_open(*_pieces, at: nil, color: nil); end
|
|
165
|
+
def box_close(*_pieces, color: nil); end
|
|
166
|
+
def queued(_text); end
|
|
167
|
+
def input_injected(_text); end
|
|
168
|
+
|
|
169
|
+
# --- Interactive: surface the approval, don't auto-deny -------------
|
|
170
|
+
|
|
171
|
+
# Option 2 — approval-surfacing. In card mode WITH an approval handler
|
|
172
|
+
# (wired by TaskTool), a child tool that needs approval is NOT silently
|
|
173
|
+
# denied: we hand off to @approve, which flips the registry entry to
|
|
174
|
+
# :needs_approval (surfacing it on the card + a parent note) and BLOCKS the
|
|
175
|
+
# child thread on a per-entry Run::ApprovalGate until the user answers via
|
|
176
|
+
# /agents <id> (or the 15-min bound auto-denies). The handler returns the
|
|
177
|
+
# boolean decision, which we return so the child's tool proceeds or denies.
|
|
178
|
+
#
|
|
179
|
+
# Without a handler (legacy inline / foreground path) we keep the old
|
|
180
|
+
# AUTO-DENY (false): a subagent there must never hang on a prompt no one can
|
|
181
|
+
# answer.
|
|
182
|
+
def confirm(question, scope: nil, **context)
|
|
183
|
+
return @approve.call(question, scope: scope, **context) if @approve
|
|
184
|
+
|
|
185
|
+
false
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# No interactive clarification mid-delegation either.
|
|
189
|
+
def ask(_prompt)
|
|
190
|
+
nil
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
# Asks the parent CLI to repaint the collapsed card block from the
|
|
196
|
+
# registry's current snapshot. Best-effort and quiet: a repaint is cosmetic
|
|
197
|
+
# and must never break the child's run. No-op when there's no parent CLI
|
|
198
|
+
# (the registry still has the fresh data for the /agents drill-in).
|
|
199
|
+
def repaint_cards
|
|
200
|
+
@parent_ui.set_subagent_cards if @parent_ui.respond_to?(:set_subagent_cards)
|
|
201
|
+
rescue StandardError
|
|
202
|
+
nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Stable palette index for a name: CRC32 keeps it deterministic across
|
|
206
|
+
# processes (Ruby's String#hash is salted per-run) and dependency-free.
|
|
207
|
+
def color_index(name)
|
|
208
|
+
Zlib.crc32(name) % PALETTE.size
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Emits one colored, indented, name-prefixed activity row.
|
|
212
|
+
def row(text)
|
|
213
|
+
return if text.nil? || text.to_s.strip.empty?
|
|
214
|
+
|
|
215
|
+
@out.puts @pastel.public_send(@color, "#{INDENT}#{GLYPH} #{@agent_name} · #{text}")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Dim variant for low-priority annotations (note/status/info).
|
|
219
|
+
def dim_row(text)
|
|
220
|
+
return if text.nil? || text.to_s.strip.empty?
|
|
221
|
+
|
|
222
|
+
@out.puts @pastel.dim("#{INDENT}#{GLYPH} #{@agent_name} · #{first_line(text, 80)}")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# A compact metric for the finish row: prefer the tool's own metrics,
|
|
226
|
+
# else a truncated preview of the output.
|
|
227
|
+
def result_metric(result)
|
|
228
|
+
return nil unless result
|
|
229
|
+
|
|
230
|
+
metric = result.metrics if result.respond_to?(:metrics)
|
|
231
|
+
return first_line(metric, 60) if metric && !metric.to_s.strip.empty?
|
|
232
|
+
|
|
233
|
+
preview = result.truncated_preview if result.respond_to?(:truncated_preview)
|
|
234
|
+
preview && !preview.to_s.strip.empty? ? first_line(preview, 60) : nil
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Short identifier piece from the tool arguments (path/pattern/command).
|
|
238
|
+
def args_hint(arguments)
|
|
239
|
+
return nil unless arguments.is_a?(Hash)
|
|
240
|
+
|
|
241
|
+
%i[file_path path pattern command].each do |k|
|
|
242
|
+
v = arguments[k] || arguments[k.to_s]
|
|
243
|
+
return first_line(v, 60) if v && !v.to_s.strip.empty?
|
|
244
|
+
end
|
|
245
|
+
nil
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# First NON-BLANK line, elided to +max+ — a multi-line ruby/shell command
|
|
249
|
+
# often starts with a blank line, which would render an empty hint (#141).
|
|
250
|
+
def first_line(text, max)
|
|
251
|
+
Rubino::Util::Output.first_line(text, max)
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
data/lib/rubino/ui.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
# UI module namespace and factory.
|
|
5
|
+
# All output in the application flows through a UI adapter.
|
|
6
|
+
module UI
|
|
7
|
+
# Factory method to build the appropriate UI adapter
|
|
8
|
+
def self.build(adapter_name)
|
|
9
|
+
case adapter_name.to_s
|
|
10
|
+
when "cli"
|
|
11
|
+
CLI.new
|
|
12
|
+
when "api"
|
|
13
|
+
API.new
|
|
14
|
+
when "null"
|
|
15
|
+
Null.new
|
|
16
|
+
else
|
|
17
|
+
raise ConfigurationError, "Unknown UI adapter: #{adapter_name}"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "time"
|
|
7
|
+
require "fileutils"
|
|
8
|
+
|
|
9
|
+
module Rubino
|
|
10
|
+
# Boot-time "new version available" notice + the `rubino update` mechanics.
|
|
11
|
+
#
|
|
12
|
+
# Two decoupled concerns, mirroring how `gh`/update-notifier do it:
|
|
13
|
+
#
|
|
14
|
+
# * SHOW (sync, zero network): `notice_from_cache` reads
|
|
15
|
+
# <RUBINO_HOME>/update_check.json and returns a one-line notice only when
|
|
16
|
+
# the cached `latest` is a valid Gem::Version strictly greater than the
|
|
17
|
+
# running VERSION. Pure local read — cannot slow boot, works offline.
|
|
18
|
+
#
|
|
19
|
+
# * REFRESH (out-of-band): `refresh_async_if_stale` spawns a detached,
|
|
20
|
+
# fully-rescued Thread (≈1.5s timeout) that GETs RubyGems and rewrites the
|
|
21
|
+
# cache for the NEXT boot. It is never joined, so this boot never blocks.
|
|
22
|
+
# Gated to once/24h, TTY-only, not-in-CI, and skipped entirely when
|
|
23
|
+
# RUBINO_NO_UPDATE_CHECK is set.
|
|
24
|
+
#
|
|
25
|
+
# The whole feature no-ops until rubino-agent is actually published: RubyGems
|
|
26
|
+
# currently returns {"version":"unknown"}, and "unknown" / non-semver / nil /
|
|
27
|
+
# any network error are all treated as "no info" → no notice.
|
|
28
|
+
module UpdateCheck
|
|
29
|
+
LATEST_URL = "https://rubygems.org/api/v1/versions/rubino-agent/latest.json"
|
|
30
|
+
GEM_NAME = "rubino-agent"
|
|
31
|
+
CACHE_FILE = "update_check.json"
|
|
32
|
+
CHECK_INTERVAL = 24 * 60 * 60 # 24h, like gh/Homebrew
|
|
33
|
+
NET_TIMEOUT = 1.5
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# ---- SHOW (pure local read) -------------------------------------------
|
|
38
|
+
|
|
39
|
+
# One-line dim notice when a newer version is cached, else nil. The
|
|
40
|
+
# RUBINO_NO_UPDATE_CHECK opt-out disables the feature ENTIRELY — "no
|
|
41
|
+
# network, no notice" per docs/commands.md — so a previously-cached
|
|
42
|
+
# notice must not leak through either (#66).
|
|
43
|
+
def notice_from_cache
|
|
44
|
+
return nil if opted_out?
|
|
45
|
+
|
|
46
|
+
latest = cached_latest
|
|
47
|
+
return nil unless newer?(latest)
|
|
48
|
+
|
|
49
|
+
"▸ rubino v#{latest} available — run `rubino update`"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# ---- REFRESH (out-of-band, never awaited) -----------------------------
|
|
53
|
+
|
|
54
|
+
# Refresh the cache in a detached thread iff enabled and stale. Returns the
|
|
55
|
+
# spawned Thread (tests can join it) or nil when gated out. The caller never
|
|
56
|
+
# joins it on the boot path, so this boot is never slowed.
|
|
57
|
+
def refresh_async_if_stale
|
|
58
|
+
return nil unless checks_enabled?
|
|
59
|
+
return nil unless stale?
|
|
60
|
+
|
|
61
|
+
Thread.new do
|
|
62
|
+
latest = fetch_latest
|
|
63
|
+
write_cache(latest) if latest
|
|
64
|
+
rescue StandardError
|
|
65
|
+
# Offline, DNS, TLS, JSON garbage, FS — silent. The cache is left as-is,
|
|
66
|
+
# so a transient failure simply shows no notice.
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# ---- network ----------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
# The latest published version string, or nil on failure / "unknown" /
|
|
74
|
+
# non-semver. Synchronous + short-timeout; callers that must not block run
|
|
75
|
+
# it on a detached thread (refresh_async_if_stale).
|
|
76
|
+
def fetch_latest
|
|
77
|
+
uri = URI(LATEST_URL)
|
|
78
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
79
|
+
http.use_ssl = (uri.scheme == "https")
|
|
80
|
+
http.open_timeout = NET_TIMEOUT
|
|
81
|
+
http.read_timeout = NET_TIMEOUT
|
|
82
|
+
|
|
83
|
+
req = Net::HTTP::Get.new(uri)
|
|
84
|
+
req["User-Agent"] = "Rubino/#{Rubino::VERSION}"
|
|
85
|
+
|
|
86
|
+
res = http.request(req)
|
|
87
|
+
return nil unless res.is_a?(Net::HTTPSuccess)
|
|
88
|
+
|
|
89
|
+
version = JSON.parse(res.body)["version"].to_s
|
|
90
|
+
semver?(version) ? version : nil
|
|
91
|
+
rescue StandardError
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ---- cache ------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def cache_path
|
|
98
|
+
File.join(Rubino::Config::Loader.default_home_path, CACHE_FILE)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def cached_latest
|
|
102
|
+
return nil unless File.exist?(cache_path)
|
|
103
|
+
|
|
104
|
+
JSON.parse(File.read(cache_path))["latest"]
|
|
105
|
+
rescue StandardError
|
|
106
|
+
nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Atomic write (temp + rename) so a crashed refresh never leaves a torn file.
|
|
110
|
+
def write_cache(latest)
|
|
111
|
+
dir = File.dirname(cache_path)
|
|
112
|
+
FileUtils.mkdir_p(dir)
|
|
113
|
+
tmp = "#{cache_path}.#{Process.pid}.tmp"
|
|
114
|
+
File.write(tmp, JSON.generate("checked_at" => Time.now.utc.iso8601, "latest" => latest))
|
|
115
|
+
File.rename(tmp, cache_path)
|
|
116
|
+
rescue StandardError
|
|
117
|
+
nil
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def clear_cache!
|
|
121
|
+
File.delete(cache_path) if File.exist?(cache_path)
|
|
122
|
+
rescue StandardError
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# ---- gating -----------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
# The user's full opt-out: RUBINO_NO_UPDATE_CHECK set (to anything
|
|
129
|
+
# non-blank) disables refresh AND the cached boot notice.
|
|
130
|
+
def opted_out?
|
|
131
|
+
!ENV["RUBINO_NO_UPDATE_CHECK"].to_s.strip.empty?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# All must hold (mirrors gh): no opt-out env, interactive TTY, not CI.
|
|
135
|
+
def checks_enabled?
|
|
136
|
+
!opted_out? &&
|
|
137
|
+
$stdout.tty? &&
|
|
138
|
+
ENV["CI"].to_s.strip.empty?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# True when the cache is missing or its checked_at is older than 24h.
|
|
142
|
+
def stale?
|
|
143
|
+
return true unless File.exist?(cache_path)
|
|
144
|
+
|
|
145
|
+
checked_at = JSON.parse(File.read(cache_path))["checked_at"]
|
|
146
|
+
Time.now.utc - Time.parse(checked_at) >= CHECK_INTERVAL
|
|
147
|
+
rescue StandardError
|
|
148
|
+
true
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# ---- update mechanics -------------------------------------------------
|
|
152
|
+
|
|
153
|
+
# How rubino was installed: :gem when a matching RubyGems spec is loaded,
|
|
154
|
+
# else :source (dev checkout / built from source / vendored).
|
|
155
|
+
def install_method
|
|
156
|
+
installed_gem_version(GEM_NAME) ? :gem : :source
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def installed_gem_version(name)
|
|
160
|
+
Gem::Specification.find_by_name(name).version.to_s
|
|
161
|
+
rescue Gem::MissingSpecError, StandardError
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Argv form (no shell) + active interpreter via Gem.ruby → updates the right
|
|
166
|
+
# install on a multi-Ruby machine and is injection-safe.
|
|
167
|
+
def gem_update_command
|
|
168
|
+
[Gem.ruby, "-S", "gem", "update", GEM_NAME]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# ---- version helpers --------------------------------------------------
|
|
172
|
+
|
|
173
|
+
# X.Y / X.Y.Z[.pre] — strict enough to reject "unknown" and other garbage.
|
|
174
|
+
def semver?(str)
|
|
175
|
+
!!(str.to_s =~ /\A\d+\.\d+(\.\d+)?([-.][0-9A-Za-z.-]+)?\z/)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# latest is a valid version strictly greater than the running VERSION.
|
|
179
|
+
def newer?(latest)
|
|
180
|
+
return false unless semver?(latest)
|
|
181
|
+
|
|
182
|
+
Gem::Version.new(latest) > Gem::Version.new(Rubino::VERSION)
|
|
183
|
+
rescue ArgumentError
|
|
184
|
+
false
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Util
|
|
5
|
+
# Compact, human-readable elapsed-time formatting shared by the agent
|
|
6
|
+
# cards and the /sessions + /agents listings (was copy-pasted into
|
|
7
|
+
# UI::SubagentCards, CLI::ChatCommand, and Commands::Executor).
|
|
8
|
+
#
|
|
9
|
+
# Coarse on purpose: seconds under a minute, then whole minutes, then
|
|
10
|
+
# whole hours — enough to read "how long" at a glance without a clock.
|
|
11
|
+
module Duration
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def human_duration(seconds)
|
|
15
|
+
secs = seconds.to_i
|
|
16
|
+
return "#{secs}s" if secs < 60
|
|
17
|
+
return "#{secs / 60}m" if secs < 3600
|
|
18
|
+
|
|
19
|
+
"#{secs / 3600}h"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Util
|
|
5
|
+
# OSC 8 terminal hyperlinks — wraps text in escape sequences that a
|
|
6
|
+
# supporting terminal renders as clickable links.
|
|
7
|
+
#
|
|
8
|
+
# Sequence shape: `\e]8;;URI\e\\LABEL\e]8;;\e\\`
|
|
9
|
+
# The first `\e]8;;` opens the link, `\e\\` (String Terminator) ends
|
|
10
|
+
# the URI segment, LABEL is what the user sees, and the trailing
|
|
11
|
+
# `\e]8;;\e\\` closes the link.
|
|
12
|
+
#
|
|
13
|
+
# ## Support detection
|
|
14
|
+
# OSC 8 is supported by iTerm2, WezTerm, vscode integrated terminal,
|
|
15
|
+
# Hyper, Ghostty, and kitty. Apple_Terminal does NOT support it and
|
|
16
|
+
# would render the escape codes as visible garbage. Detection is
|
|
17
|
+
# CONSERVATIVE: unknown terminals default to off, so users on
|
|
18
|
+
# Terminal.app or a tmux session whose outer terminal we can't
|
|
19
|
+
# introspect never see junk in their scrollback.
|
|
20
|
+
#
|
|
21
|
+
# Override with `RUBINO_HYPERLINKS=1` to force on (useful in
|
|
22
|
+
# tmux when you know the outer terminal supports OSC 8) or `=0` to
|
|
23
|
+
# force off. `NO_COLOR=1` also forces off, matching the broader
|
|
24
|
+
# convention used by every other ANSI-emitting tool in this CLI.
|
|
25
|
+
#
|
|
26
|
+
# ## Scope
|
|
27
|
+
# OSC 8 lives ENTIRELY in the CLI adapter. The API adapter emits raw
|
|
28
|
+
# structured events (tool name, arguments hash) and the web UI builds
|
|
29
|
+
# its own `<a>` elements from that — terminal escape codes have no
|
|
30
|
+
# business inside a JSON payload.
|
|
31
|
+
module Hyperlink
|
|
32
|
+
OPEN_PREFIX = "\e]8;;"
|
|
33
|
+
CLOSE_SUFFIX = "\e]8;;\e\\"
|
|
34
|
+
ST = "\e\\" # String Terminator
|
|
35
|
+
|
|
36
|
+
# Terminals known to render OSC 8 correctly. Conservative list —
|
|
37
|
+
# additions welcome as we confirm support elsewhere.
|
|
38
|
+
KNOWN_TERM_PROGRAMS = %w[iTerm.app WezTerm vscode Hyper ghostty].freeze
|
|
39
|
+
|
|
40
|
+
# True when the current terminal renders OSC 8 hyperlinks. Result is
|
|
41
|
+
# cached per process because env vars don't change mid-run.
|
|
42
|
+
def self.supported?
|
|
43
|
+
return @supported if defined?(@supported)
|
|
44
|
+
|
|
45
|
+
@supported = compute_support
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Test-only hook to reset the memoized support flag (specs flip env
|
|
49
|
+
# vars between examples). Not part of the public contract.
|
|
50
|
+
def self.reset!
|
|
51
|
+
remove_instance_variable(:@supported) if defined?(@supported)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Wraps LABEL in the OSC 8 sequence pointing to URI. Returns LABEL
|
|
55
|
+
# unchanged when hyperlinks aren't supported, so callers can use the
|
|
56
|
+
# result unconditionally — no escape codes leak into a Terminal.app
|
|
57
|
+
# scrollback or an SSE payload.
|
|
58
|
+
def self.wrap(label, uri:)
|
|
59
|
+
return label.to_s if label.nil?
|
|
60
|
+
return label.to_s unless supported?
|
|
61
|
+
return label.to_s if uri.nil? || uri.to_s.empty?
|
|
62
|
+
|
|
63
|
+
"#{OPEN_PREFIX}#{uri}#{ST}#{label}#{CLOSE_SUFFIX}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Builds a `file://` URI for the given path, expanding to absolute
|
|
67
|
+
# so the terminal's URI handler doesn't try to resolve it against
|
|
68
|
+
# its own cwd. Returns nil when the path is empty or doesn't exist
|
|
69
|
+
# — callers should fall back to the raw label in that case.
|
|
70
|
+
def self.file_uri(path)
|
|
71
|
+
return nil if path.nil? || path.to_s.empty?
|
|
72
|
+
|
|
73
|
+
abs = File.expand_path(path.to_s)
|
|
74
|
+
return nil unless File.exist?(abs)
|
|
75
|
+
|
|
76
|
+
"file://#{abs}"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Convenience for the common case: "I have a file path, wrap it as
|
|
80
|
+
# a clickable link to that file." Pass a different `label:` when
|
|
81
|
+
# the displayed text differs from the path (e.g. truncated to fit
|
|
82
|
+
# a header rule).
|
|
83
|
+
def self.wrap_path(path, label: nil)
|
|
84
|
+
uri = file_uri(path)
|
|
85
|
+
text = (label || path).to_s
|
|
86
|
+
return text if uri.nil?
|
|
87
|
+
|
|
88
|
+
wrap(text, uri: uri)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
class << self
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def compute_support
|
|
95
|
+
return false if ENV["NO_COLOR"] && !ENV["NO_COLOR"].empty?
|
|
96
|
+
return true if ENV["RUBINO_HYPERLINKS"] == "1"
|
|
97
|
+
return false if ENV["RUBINO_HYPERLINKS"] == "0"
|
|
98
|
+
return true if ENV["TERM"] == "xterm-kitty"
|
|
99
|
+
|
|
100
|
+
KNOWN_TERM_PROGRAMS.include?(ENV.fetch("TERM_PROGRAM", nil))
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|