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,550 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Commands
|
|
5
|
+
# Executes a slash command, rendering its template and feeding it to the agent.
|
|
6
|
+
#
|
|
7
|
+
# `runner:` (optional) is the live Agent::Runner for the interactive REPL.
|
|
8
|
+
# When present, `/status` and `/sessions` can read the current session / model
|
|
9
|
+
# straight off it. It is nil for non-interactive callers (and unit tests that
|
|
10
|
+
# don't exercise those commands), in which case those commands degrade
|
|
11
|
+
# gracefully instead of raising.
|
|
12
|
+
class Executor
|
|
13
|
+
# How many model ids the bare `/model` listing renders before deferring
|
|
14
|
+
# the rest to the completion dropdown.
|
|
15
|
+
MODEL_LIST_LIMIT = 12
|
|
16
|
+
|
|
17
|
+
def initialize(loader: nil, ui: nil, runner: nil)
|
|
18
|
+
@loader = loader || Loader.new
|
|
19
|
+
@ui = ui || Rubino.ui
|
|
20
|
+
@runner = runner
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Attempts to execute input as a slash command.
|
|
24
|
+
# Returns the rendered prompt if it's a command, nil otherwise.
|
|
25
|
+
def try_execute(input)
|
|
26
|
+
return nil unless @loader.slash_command?(input)
|
|
27
|
+
|
|
28
|
+
name, arguments = @loader.parse(input)
|
|
29
|
+
return nil unless name
|
|
30
|
+
|
|
31
|
+
# Check built-in commands first
|
|
32
|
+
built_in_result = handle_built_in(name, arguments)
|
|
33
|
+
return built_in_result if built_in_result
|
|
34
|
+
|
|
35
|
+
# Look up custom command
|
|
36
|
+
command = @loader.find(name)
|
|
37
|
+
unless command
|
|
38
|
+
@ui.error("unknown command: /#{name}")
|
|
39
|
+
@ui.info("Available: #{help_handler.available_commands.join(", ")}")
|
|
40
|
+
return :handled # Signal that it was handled (even if failed)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
run_custom_command(command, name, arguments)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Renders the welcome variant on first interactive boot. Best-effort: a
|
|
47
|
+
# welcome banner must never block the REPL from starting, so any assembler
|
|
48
|
+
# hiccup degrades to no banner rather than a crash. The boot header
|
|
49
|
+
# (workspace/branch/model) is printed by the chat command; this adds only
|
|
50
|
+
# the orientation, with no duplicate identity/session-id renderings.
|
|
51
|
+
def self.welcome(runner: nil, ui: nil)
|
|
52
|
+
new(ui: ui, runner: runner).send(:show_welcome)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Renders a custom command. `--preview` (anywhere in the arguments) shows
|
|
60
|
+
# the resolved prompt and asks for confirmation before sending it to the
|
|
61
|
+
# agent, so the user can see exactly what a command expands to first.
|
|
62
|
+
def run_custom_command(command, name, arguments)
|
|
63
|
+
args, preview = strip_preview_flag(arguments)
|
|
64
|
+
rendered = command.render(args)
|
|
65
|
+
|
|
66
|
+
if preview
|
|
67
|
+
show_command_preview(name, rendered)
|
|
68
|
+
return :handled unless confirm_run?(name)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
@ui.status("Running command: /#{name}")
|
|
72
|
+
{ prompt: rendered, agent: command.agent, model: command.model }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Splits a `--preview` flag out of the argument string, returning
|
|
76
|
+
# [remaining_args, preview?]. Matches `--preview` as a standalone token so
|
|
77
|
+
# it isn't mistaken for part of a longer argument.
|
|
78
|
+
def strip_preview_flag(arguments)
|
|
79
|
+
tokens = arguments.to_s.split(/\s+/)
|
|
80
|
+
preview = tokens.delete("--preview") ? true : false
|
|
81
|
+
[tokens.join(" "), preview]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def show_command_preview(name, rendered)
|
|
85
|
+
@ui.info("Preview of /#{name} — the prompt that would be sent:")
|
|
86
|
+
@ui.separator
|
|
87
|
+
@ui.info(rendered)
|
|
88
|
+
@ui.separator
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Asks for confirmation before running a previewed command. The UI #ask
|
|
92
|
+
# returns nil for non-interactive adapters (Null/API), in which case we
|
|
93
|
+
# treat it as "no" so a preview never auto-fires without a human.
|
|
94
|
+
def confirm_run?(name)
|
|
95
|
+
answer = @ui.ask("Run /#{name}? [y/N] ")
|
|
96
|
+
answer.to_s.strip.downcase.start_with?("y")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def handle_built_in(name, arguments)
|
|
100
|
+
case name
|
|
101
|
+
when "help"
|
|
102
|
+
help_handler.show_help
|
|
103
|
+
:handled
|
|
104
|
+
when "exit", "quit"
|
|
105
|
+
:exit
|
|
106
|
+
when "commands"
|
|
107
|
+
help_handler.show_commands
|
|
108
|
+
:handled
|
|
109
|
+
when "skills"
|
|
110
|
+
skills_handler.handle_skills(arguments)
|
|
111
|
+
:handled
|
|
112
|
+
when "mcp"
|
|
113
|
+
mcp_handler.handle_mcp(arguments)
|
|
114
|
+
:handled
|
|
115
|
+
when "add-dir"
|
|
116
|
+
handle_add_dir(arguments)
|
|
117
|
+
:handled
|
|
118
|
+
when "dirs"
|
|
119
|
+
show_dirs
|
|
120
|
+
:handled
|
|
121
|
+
when "mode"
|
|
122
|
+
handle_mode(arguments)
|
|
123
|
+
:handled
|
|
124
|
+
when "model"
|
|
125
|
+
handle_model(arguments)
|
|
126
|
+
:handled
|
|
127
|
+
when "compact"
|
|
128
|
+
handle_compact
|
|
129
|
+
when "export"
|
|
130
|
+
handle_export(arguments)
|
|
131
|
+
:handled
|
|
132
|
+
when "reasoning"
|
|
133
|
+
handle_reasoning(arguments)
|
|
134
|
+
:handled
|
|
135
|
+
when "think"
|
|
136
|
+
handle_think(arguments)
|
|
137
|
+
:handled
|
|
138
|
+
when "status"
|
|
139
|
+
status_handler.show_status
|
|
140
|
+
:handled
|
|
141
|
+
when "memory"
|
|
142
|
+
memory_handler.handle_memory(arguments)
|
|
143
|
+
:handled
|
|
144
|
+
when "jobs"
|
|
145
|
+
jobs_handler.handle_jobs(arguments)
|
|
146
|
+
:handled
|
|
147
|
+
when "config"
|
|
148
|
+
config_handler.handle_config(arguments)
|
|
149
|
+
:handled
|
|
150
|
+
when "agents", "tasks"
|
|
151
|
+
agents_handler.handle_agents(arguments)
|
|
152
|
+
# handle_agents delegates to the puts-based UI (info/table), whose
|
|
153
|
+
# methods return nil; without an explicit :handled the falsy result
|
|
154
|
+
# makes try_execute fall through to the unknown-command path (#34).
|
|
155
|
+
:handled
|
|
156
|
+
when "reply"
|
|
157
|
+
agents_handler.handle_reply(arguments)
|
|
158
|
+
:handled
|
|
159
|
+
when "sessions"
|
|
160
|
+
sessions_handler.handle_sessions(arguments)
|
|
161
|
+
when "probe"
|
|
162
|
+
# `/probe <text>` is the discoverable alias for the `? ` prefix. We
|
|
163
|
+
# don't run the side-inference here (the Executor has no LLM seam) —
|
|
164
|
+
# we hand the REPL a {probe:} signal it runs against the live runner's
|
|
165
|
+
# session, then renders+discards. Bare `/probe` just teaches the tip.
|
|
166
|
+
sessions_handler.handle_probe(arguments)
|
|
167
|
+
when "queued"
|
|
168
|
+
# `/queued <msg>` is normally intercepted by the BottomComposer
|
|
169
|
+
# before it ever reaches the Executor (it queues the message for the
|
|
170
|
+
# next turn, like Alt+Enter). Reaching here means there was nothing
|
|
171
|
+
# to queue (bare `/queued`) or no composer owns the input (API/piped
|
|
172
|
+
# mode) — teach the usage instead of "Unknown command".
|
|
173
|
+
@ui.info("Queue a message to run after the current turn: /queued <message>")
|
|
174
|
+
@ui.info("(Alt+Enter queues the current input line the same way; " \
|
|
175
|
+
"plain Enter interrupts the turn and runs the line next.)")
|
|
176
|
+
:handled
|
|
177
|
+
when "branch"
|
|
178
|
+
# `/branch [name]` forks the CURRENT session at this point into a new
|
|
179
|
+
# saved one and switches into it. The REPL owns the runner/session, so
|
|
180
|
+
# we return a {branch_from:, title:} signal on the SAME channel /new
|
|
181
|
+
# and /sessions use, and it does the build/seed/swap.
|
|
182
|
+
sessions_handler.handle_branch(arguments)
|
|
183
|
+
when "new", "clear"
|
|
184
|
+
# Hand the REPL a signal to rebuild the runner on a brand-new session.
|
|
185
|
+
# The current session is left intact (and will be marked ended on the
|
|
186
|
+
# eventual teardown), so /new is the in-chat counterpart to `--new`.
|
|
187
|
+
# /clear is the muscle-memory alias every other agent CLI ships.
|
|
188
|
+
@ui.success("Starting a fresh session.")
|
|
189
|
+
{ new_session: true }
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The domain handlers the dispatcher delegates to (#193 collaborator
|
|
194
|
+
# pattern). Each is a plain object given the deps it needs (ui/runner);
|
|
195
|
+
# the Executor stays the thin dispatcher/facade over the slash-command
|
|
196
|
+
# case. Memoized so each carries its own per-session state (e.g. the
|
|
197
|
+
# memory backend memo, the watch pastel).
|
|
198
|
+
def agents_handler
|
|
199
|
+
@agents_handler ||= Handlers::Agents.new(ui: @ui)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def sessions_handler
|
|
203
|
+
@sessions_handler ||= Handlers::Sessions.new(ui: @ui, runner: @runner)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def status_handler
|
|
207
|
+
@status_handler ||= Handlers::Status.new(ui: @ui, runner: @runner)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def memory_handler
|
|
211
|
+
@memory_handler ||= Handlers::Memory.new(ui: @ui)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def skills_handler
|
|
215
|
+
@skills_handler ||= Handlers::Skills.new(ui: @ui)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def mcp_handler
|
|
219
|
+
@mcp_handler ||= Handlers::MCP.new(ui: @ui)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def jobs_handler
|
|
223
|
+
@jobs_handler ||= Handlers::Jobs.new(ui: @ui)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def config_handler
|
|
227
|
+
@config_handler ||= Handlers::Config.new(ui: @ui)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def help_handler
|
|
231
|
+
@help_handler ||= Handlers::Help.new(ui: @ui, loader: @loader)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# `/mode` → show current + list
|
|
235
|
+
# `/mode list` → same
|
|
236
|
+
# `/mode <name>` → switch (default | plan | yolo)
|
|
237
|
+
#
|
|
238
|
+
# We delegate the actual transition to Rubino::Modes.set so the API
|
|
239
|
+
# adapter and any other caller go through the same gate (and trigger
|
|
240
|
+
# the same `mode_changed` UI event).
|
|
241
|
+
def handle_mode(arguments)
|
|
242
|
+
name = arguments.to_s.strip.downcase.split(/\s+/).first
|
|
243
|
+
|
|
244
|
+
if name.nil? || name.empty? || name == "list"
|
|
245
|
+
show_modes
|
|
246
|
+
return
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
previous = Rubino::Modes.current
|
|
250
|
+
Rubino::Modes.set(name)
|
|
251
|
+
@ui.mode_changed(Rubino::Modes.current, previous: previous)
|
|
252
|
+
warn_yolo_live_children(previous)
|
|
253
|
+
rescue ArgumentError => e
|
|
254
|
+
@ui.error(e.message)
|
|
255
|
+
@ui.info("Available: #{Rubino::Modes::ALL.join(", ")}")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# One warning line when an explicit `/mode yolo` lands while background
|
|
259
|
+
# children are live (#152): the gates of already-running subagents drop
|
|
260
|
+
# the moment the mode flips, which is easy to forget mid-session. The
|
|
261
|
+
# explicit command stays unconfirmed — this is information, not friction.
|
|
262
|
+
def warn_yolo_live_children(previous)
|
|
263
|
+
return unless Rubino::Modes.current == Rubino::Modes::YOLO && previous != Rubino::Modes::YOLO
|
|
264
|
+
|
|
265
|
+
live = Tools::BackgroundTasks.instance.running.size
|
|
266
|
+
return unless live.positive?
|
|
267
|
+
|
|
268
|
+
@ui.warning("⚡ yolo: #{live} running background subagent(s) will now run gated actions unprompted")
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def show_modes
|
|
272
|
+
current = Rubino::Modes.current
|
|
273
|
+
@ui.info("Current mode: #{current} — #{Rubino::Modes.description(current)}")
|
|
274
|
+
@ui.info("Available:")
|
|
275
|
+
Rubino::Modes::ALL.each do |m|
|
|
276
|
+
marker = m == current ? "▸" : " "
|
|
277
|
+
@ui.info(" #{marker} /mode #{m} — #{Rubino::Modes.description(m)}")
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# `/reasoning` → show current render mode
|
|
282
|
+
# `/reasoning <mode>` → switch (hidden | collapsed | full)
|
|
283
|
+
#
|
|
284
|
+
# Writes the new mode to display.reasoning on the live configuration so the
|
|
285
|
+
# LLM adapter gate (which reads config) and the CLI render path share one
|
|
286
|
+
# source of truth — no separate per-UI override to drift. An unknown value
|
|
287
|
+
# is rejected with the valid list.
|
|
288
|
+
def handle_reasoning(arguments)
|
|
289
|
+
name = arguments.to_s.strip.downcase.split(/\s+/).first
|
|
290
|
+
previous = Config::ReasoningPrefs.mode(Rubino.configuration)
|
|
291
|
+
|
|
292
|
+
if name.nil? || name.empty?
|
|
293
|
+
@ui.reasoning_status(previous) if @ui.respond_to?(:reasoning_status)
|
|
294
|
+
return
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
sym = name.to_sym
|
|
298
|
+
unless Config::ReasoningPrefs::RENDER_MODES.include?(sym)
|
|
299
|
+
@ui.error("unknown reasoning mode: #{name}")
|
|
300
|
+
@ui.info("Available: #{Config::ReasoningPrefs::RENDER_MODES.join(", ")}")
|
|
301
|
+
return
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
Rubino.configuration.set("display", "reasoning", sym.to_s)
|
|
305
|
+
persist_config("display.reasoning", sym.to_s)
|
|
306
|
+
@ui.reasoning_changed(sym, previous: previous) if @ui.respond_to?(:reasoning_changed)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# `/think` → show current effort
|
|
310
|
+
# `/think <level>` → switch (off | low | medium | high)
|
|
311
|
+
#
|
|
312
|
+
# Writes thinking.effort on the live configuration; the adapter derives the
|
|
313
|
+
# thinking-token budget from it on the next turn. An unknown value is
|
|
314
|
+
# rejected with the valid list.
|
|
315
|
+
def handle_think(arguments)
|
|
316
|
+
name = arguments.to_s.strip.downcase.split(/\s+/).first
|
|
317
|
+
previous = Config::ReasoningPrefs.effort(Rubino.configuration) ||
|
|
318
|
+
Config::ReasoningPrefs::DEFAULT_EFFORT
|
|
319
|
+
|
|
320
|
+
if name.nil? || name.empty?
|
|
321
|
+
@ui.think_status(previous) if @ui.respond_to?(:think_status)
|
|
322
|
+
return
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
sym = name.to_sym
|
|
326
|
+
unless Config::ReasoningPrefs::EFFORTS.include?(sym)
|
|
327
|
+
@ui.error("unknown effort: #{name}")
|
|
328
|
+
@ui.info("Available: #{Config::ReasoningPrefs::EFFORTS.join(", ")}")
|
|
329
|
+
return
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
Rubino.configuration.set("thinking", "effort", sym.to_s)
|
|
333
|
+
persist_config("thinking.effort", sym.to_s)
|
|
334
|
+
@ui.think_changed(sym, previous: previous) if @ui.respond_to?(:think_changed)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# `/model` → show current model/provider + the known models
|
|
338
|
+
# `/model <name>` → switch the LIVE session model
|
|
339
|
+
#
|
|
340
|
+
# The switch writes model.default through Config::Writer (the same
|
|
341
|
+
# persist path /think uses) AND retargets the live runner, so the very
|
|
342
|
+
# next turn hits the new model — no restart. The known-models list comes
|
|
343
|
+
# from the ruby_llm registry for the ACTIVE provider; custom backends
|
|
344
|
+
# (minimax/gateway) aren't enumerable there, so they degrade to the
|
|
345
|
+
# current model + a usage hint instead of an invented hardcoded list.
|
|
346
|
+
def handle_model(arguments)
|
|
347
|
+
name = arguments.to_s.strip.split(/\s+/).first
|
|
348
|
+
|
|
349
|
+
if name.nil? || name.empty?
|
|
350
|
+
show_model
|
|
351
|
+
return
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
previous = status_model
|
|
355
|
+
if name == previous
|
|
356
|
+
@ui.info("Already on #{name}.")
|
|
357
|
+
return
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
Rubino.configuration.set("model", "default", name)
|
|
361
|
+
persist_config("model.default", name)
|
|
362
|
+
@runner.switch_model!(name) if @runner.respond_to?(:switch_model!)
|
|
363
|
+
# Forget per-provider thinking rejections recorded this session: the
|
|
364
|
+
# new model may sit on a provider that does support a budget (and the
|
|
365
|
+
# MiniMax-family default is re-derived per turn from the new id).
|
|
366
|
+
LLM::ThinkingSupport.reset!
|
|
367
|
+
@ui.success("model: #{previous} → #{name} (persisted; applies from the next turn)")
|
|
368
|
+
warn_cross_provider_model(name)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def show_model
|
|
372
|
+
current = status_model
|
|
373
|
+
provider = active_provider(current)
|
|
374
|
+
@ui.info("Current model: #{current} (provider: #{provider})")
|
|
375
|
+
|
|
376
|
+
ids = LLM::ModelCatalog.ids_for(provider)
|
|
377
|
+
if ids.empty?
|
|
378
|
+
@ui.info("No model catalog for provider '#{provider}' — /model <name> switches anyway.")
|
|
379
|
+
return
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
@ui.info("Known models for #{provider}:")
|
|
383
|
+
ids.first(MODEL_LIST_LIMIT).each do |id|
|
|
384
|
+
marker = id == current ? "▸" : " "
|
|
385
|
+
@ui.info(" #{marker} /model #{id}")
|
|
386
|
+
end
|
|
387
|
+
rest = ids.size - MODEL_LIST_LIMIT
|
|
388
|
+
@ui.info(" … and #{rest} more (type `/model ` for the full dropdown)") if rest.positive?
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# The model the next turn will run on — the live session's model, the
|
|
392
|
+
# runner's model_id, or the configured default (in that order). Shared by
|
|
393
|
+
# /model (here) and the /status panel (Handlers::Status reads it too).
|
|
394
|
+
def status_model
|
|
395
|
+
@runner&.session&.dig(:model) ||
|
|
396
|
+
(@runner.respond_to?(:model_id) ? @runner.model_id : nil) ||
|
|
397
|
+
Rubino.configuration.model_default
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# The provider the next turn will actually route through — the single
|
|
401
|
+
# ProviderResolver seam AdapterFactory uses, fed with the configured
|
|
402
|
+
# explicit provider (or "auto" pattern-matching the model id).
|
|
403
|
+
def active_provider(model_id)
|
|
404
|
+
LLM::ProviderResolver.resolve(model_id, explicit_provider: Rubino.configuration.model_provider)
|
|
405
|
+
rescue StandardError
|
|
406
|
+
"(unknown)"
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# An explicit model.provider pins routing regardless of model id, so
|
|
410
|
+
# `/model claude-x` under provider "minimax" keeps hitting MiniMax's
|
|
411
|
+
# endpoint. One informational line when the new id pattern-matches a
|
|
412
|
+
# different provider than the pinned one — gateway excepted, since a
|
|
413
|
+
# gateway proxies arbitrary model ids by design.
|
|
414
|
+
def warn_cross_provider_model(model_id)
|
|
415
|
+
explicit = Rubino.configuration.model_provider
|
|
416
|
+
return if explicit.nil? || explicit == "auto" || explicit == "gateway"
|
|
417
|
+
|
|
418
|
+
implied = LLM::ProviderResolver.resolve(model_id)
|
|
419
|
+
return if implied == explicit
|
|
420
|
+
|
|
421
|
+
@ui.info("Requests still route via provider '#{explicit}' — set model.provider to switch backends.")
|
|
422
|
+
rescue StandardError
|
|
423
|
+
nil
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# `/compact` — manual compaction NOW, the same Context::Compressor +
|
|
427
|
+
# compression_started/finished pipeline the automatic threshold path
|
|
428
|
+
# runs, plus a tokens before→after report. Compaction lands in a CHILD
|
|
429
|
+
# session (head + summary + tail), so on success we hand the REPL a
|
|
430
|
+
# {compact_into:} signal and it swaps the runner into the child — the
|
|
431
|
+
# next turn runs on the compacted context.
|
|
432
|
+
def handle_compact
|
|
433
|
+
session = @runner&.session
|
|
434
|
+
unless session && Session::Repository.new.persisted?(session[:id])
|
|
435
|
+
@ui.error("nothing to compact — this session has no saved messages yet")
|
|
436
|
+
return :handled
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
store = Session::Store.new
|
|
440
|
+
before = estimate_session_tokens(store, session[:id], model_id: session[:model])
|
|
441
|
+
|
|
442
|
+
@ui.compression_started
|
|
443
|
+
result = Context::Compressor.new(session_id: session[:id]).compact!
|
|
444
|
+
|
|
445
|
+
if result[:skipped]
|
|
446
|
+
@ui.info("Nothing to compact yet — the session is still below the protected head/tail size.")
|
|
447
|
+
return :handled
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
@ui.compression_finished(result)
|
|
451
|
+
after = estimate_session_tokens(store, result[:target_session_id], model_id: session[:model])
|
|
452
|
+
@ui.info("Context: ~#{before} → ~#{after} tokens (#{result[:original_messages]} → " \
|
|
453
|
+
"#{result[:compacted_messages]} messages).")
|
|
454
|
+
{ compact_into: result[:target_session_id] }
|
|
455
|
+
rescue StandardError => e
|
|
456
|
+
@ui.error("compaction failed: #{e.message}")
|
|
457
|
+
:handled
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# The same chars/4 estimate the compaction thresholds and the status bar
|
|
461
|
+
# run on, over a session's stored messages.
|
|
462
|
+
def estimate_session_tokens(store, session_id, model_id:)
|
|
463
|
+
budget = Context::TokenBudget.new(model_id: model_id, config: Rubino.configuration)
|
|
464
|
+
budget.estimate_tokens(store.for_session(session_id).map { |m| { content: m.content } })
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# `/export [path]` — write the session transcript as clean markdown via
|
|
468
|
+
# Session::Exporter (user/assistant turns, tool calls as one-liners,
|
|
469
|
+
# reasoning omitted). Default path ./rubino-session-<id8>.md.
|
|
470
|
+
def handle_export(arguments)
|
|
471
|
+
session = @runner&.session
|
|
472
|
+
unless session
|
|
473
|
+
@ui.error("no live session to export")
|
|
474
|
+
return
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
path = arguments.to_s.strip
|
|
478
|
+
target = Session::Exporter.new(session).write(path.empty? ? nil : path)
|
|
479
|
+
@ui.success("exported → #{target}")
|
|
480
|
+
rescue StandardError => e
|
|
481
|
+
@ui.error("export failed: #{e.message}")
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Write-through of a /reasoning // /think switch to config.yml so it
|
|
485
|
+
# survives the session, as docs/commands.md promises (#131). The in-memory
|
|
486
|
+
# set above stays authoritative for THIS session either way; a disk
|
|
487
|
+
# failure degrades to the old session-only behavior with a warning, never
|
|
488
|
+
# a broken command.
|
|
489
|
+
def persist_config(key_path, value)
|
|
490
|
+
path = Config::Loader.new.config_path
|
|
491
|
+
Config::Writer.new(config_path: path).set(key_path, value)
|
|
492
|
+
rescue StandardError => e
|
|
493
|
+
@ui.warning("could not persist #{key_path} to config: #{e.message}")
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# --- welcome -----------------------------------------------------------
|
|
497
|
+
#
|
|
498
|
+
# First-run guidance — the counterpart to /status (Handlers::Status).
|
|
499
|
+
# `.welcome` (public, above) orients a newcomer: one identity line + what
|
|
500
|
+
# to DO next. NOT the state dump (#82); the at-a-glance panel lives in
|
|
501
|
+
# /status. This is the private instance method it calls.
|
|
502
|
+
|
|
503
|
+
# Color diet (P8): ONE cyan identity line; the hint commands are the
|
|
504
|
+
# only other accent (they're actionable pointers); descriptions plain.
|
|
505
|
+
def show_welcome
|
|
506
|
+
@ui.separator
|
|
507
|
+
@ui.info("rubino — ask in plain language; it reads, edits, and runs things for you.")
|
|
508
|
+
@ui.blank_line
|
|
509
|
+
@ui.status(" Ask anything, or try:")
|
|
510
|
+
@ui.hint_row("/status", "what's going on right now")
|
|
511
|
+
@ui.hint_row("/sessions", "resume past work")
|
|
512
|
+
@ui.hint_row("/memory", "what I recall about you")
|
|
513
|
+
@ui.hint_row("/help", "all commands and keys")
|
|
514
|
+
@ui.separator
|
|
515
|
+
end
|
|
516
|
+
# --- /add-dir & /dirs --------------------------------------------------
|
|
517
|
+
#
|
|
518
|
+
# Mid-session workspace management, mirroring Claude Code's --add-dir.
|
|
519
|
+
# `/add-dir <path>` adds an extra allowed root (write/edit can then reach
|
|
520
|
+
# files under it) and runs the folder-trust gate so its AGENTS.md/skills
|
|
521
|
+
# are only honored once vouched for. `/dirs` lists the current roots.
|
|
522
|
+
|
|
523
|
+
def handle_add_dir(arguments)
|
|
524
|
+
path = arguments.to_s.strip
|
|
525
|
+
if path.empty?
|
|
526
|
+
@ui.info("Usage: /add-dir <path> — adds an extra allowed workspace directory.")
|
|
527
|
+
return
|
|
528
|
+
end
|
|
529
|
+
|
|
530
|
+
real = Rubino::Workspace.add(path)
|
|
531
|
+
@ui.success("Added workspace root: #{real}")
|
|
532
|
+
# Gate the freshly-added dir interactively (same one-time prompt as boot).
|
|
533
|
+
CLI::TrustGate.new(ui: @ui, interactive: true).ensure_trust(real)
|
|
534
|
+
rescue ArgumentError => e
|
|
535
|
+
@ui.error("/add-dir #{path}: #{e.message}")
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
def show_dirs
|
|
539
|
+
roots = Rubino::Workspace.canonical_roots
|
|
540
|
+
@ui.info("Workspace roots (#{roots.size}):")
|
|
541
|
+
roots.each_with_index do |dir, i|
|
|
542
|
+
marker = i.zero? ? "▸" : " "
|
|
543
|
+
trust = Rubino::Trust.trusted?(dir) ? "" : " (untrusted — context/skills withheld)"
|
|
544
|
+
@ui.info(" #{marker} #{dir}#{trust}")
|
|
545
|
+
end
|
|
546
|
+
@ui.info("Add more with /add-dir <path>")
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
end
|
|
550
|
+
end
|