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,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module CLI
|
|
5
|
+
module Chat
|
|
6
|
+
# Builds the composer's CompletionSource: the `/command` + `@file` candidates
|
|
7
|
+
# plus the per-command ARGUMENT grammars (the dropdown that completes the
|
|
8
|
+
# argument of /skills, /agents, /mcp, /sessions, /memory, /config, … the same
|
|
9
|
+
# way it completes a command or a file). Extracted out of ChatCommand — a
|
|
10
|
+
# self-contained candidate/data-generation block (#17 collaborator pattern).
|
|
11
|
+
#
|
|
12
|
+
# Given the command loader it needs; every source is best-effort (a DB or
|
|
13
|
+
# registry hiccup degrades to no candidates, never a broken prompt) and read
|
|
14
|
+
# lazily on each dropdown open so a /model, /config or skill change is
|
|
15
|
+
# reflected immediately.
|
|
16
|
+
class CompletionBuilder
|
|
17
|
+
# The /agents subcommand grammar offered by the dropdown (#39): first an
|
|
18
|
+
# id, then what you can do to it.
|
|
19
|
+
AGENTS_SUBCOMMANDS = ["steer", "probe", "--stop"].freeze
|
|
20
|
+
|
|
21
|
+
# The /mcp subcommand grammar (#182): configured server names + reload
|
|
22
|
+
# first, then the on/off verbs for a named server.
|
|
23
|
+
MCP_SUBCOMMANDS = %w[on off].freeze
|
|
24
|
+
|
|
25
|
+
# The /sessions subcommand grammar (#183): verbs + recent session ids
|
|
26
|
+
# first (bare id resumes, verb then id shows/deletes), then ids after a
|
|
27
|
+
# verb. Mirrors the /agents grammar so the picker teaches the surface.
|
|
28
|
+
SESSIONS_SUBCOMMANDS = ["show", "delete", "--all"].freeze
|
|
29
|
+
|
|
30
|
+
# The /memory subcommand grammar (#184): verbs first, then recent fact
|
|
31
|
+
# ids after show/forget (short ids — the store resolves prefixes) or the
|
|
32
|
+
# registered backend names after backend.
|
|
33
|
+
MEMORY_SUBCOMMANDS = ["search", "show", "forget", "backend", "--all"].freeze
|
|
34
|
+
|
|
35
|
+
# The /skills grammar (#188): position one mixes the `✗ none` clear entry
|
|
36
|
+
# (CompletionSource keeps its special matching), the enable/disable verbs
|
|
37
|
+
# and the activate-by-name skill list; after a toggle verb, the names
|
|
38
|
+
# complete again. Activate-by-name and `✗ none` behave exactly as before.
|
|
39
|
+
SKILLS_SUBCOMMANDS = %w[enable disable].freeze
|
|
40
|
+
|
|
41
|
+
# The /config grammar (#187): verbs + the known config keys first (a
|
|
42
|
+
# bare key gets, key+value sets), keys again after get/set.
|
|
43
|
+
CONFIG_SUBCOMMANDS = %w[get set show path].freeze
|
|
44
|
+
|
|
45
|
+
def initialize(cmd_loader)
|
|
46
|
+
@cmd_loader = cmd_loader
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def build
|
|
50
|
+
custom = begin
|
|
51
|
+
@cmd_loader.names
|
|
52
|
+
rescue StandardError
|
|
53
|
+
[]
|
|
54
|
+
end
|
|
55
|
+
names = (::Rubino::Commands::BuiltIns::NAMES + custom).uniq
|
|
56
|
+
files = -> { Rubino::Workspace.primary_root }
|
|
57
|
+
# ARGUMENT sources: the dropdown completes the argument of these commands
|
|
58
|
+
# the same way it completes `/command` and `@file`.
|
|
59
|
+
# * /skills <partial> — a skill name (lazily re-read each open so a
|
|
60
|
+
# freshly-authored skill appears), TRUST-aligned with the prompt
|
|
61
|
+
# assembler (#63) so the picker never offers a skill that won't pin.
|
|
62
|
+
# * /agents (alias /tasks) — the live subagent ids, then the
|
|
63
|
+
# steer/probe/--stop subcommand grammar, so the comm surface is
|
|
64
|
+
# discoverable from the composer (#39).
|
|
65
|
+
# * /reply — the ids of children blocked waiting on the human.
|
|
66
|
+
# * /mcp — the configured server names (+ reload), then on/off for a
|
|
67
|
+
# named server (#182), same grammar shape as /agents.
|
|
68
|
+
# * /mode, /reasoning, /think — the closed enums (#185), via the
|
|
69
|
+
# positional shape so no `✗ none` clear entry is injected (there
|
|
70
|
+
# is no "clear" for a mode — see CompletionSource#initialize).
|
|
71
|
+
# * /model — the ruby_llm-registry model ids for the active provider
|
|
72
|
+
# (empty for custom backends like minimax/gateway, which aren't
|
|
73
|
+
# enumerable — the dropdown just shows nothing extra there).
|
|
74
|
+
# * /add-dir — filesystem DIRECTORY candidates from the typed
|
|
75
|
+
# partial (#185), via the partial-aware two-arg shape.
|
|
76
|
+
# * /sessions, /memory — verbs + recent ids (#183/#184), the same
|
|
77
|
+
# per-position grammar /agents ships.
|
|
78
|
+
# * /jobs — recent job ids (#187); /config — the get/set/show/path
|
|
79
|
+
# verbs + the known config keys flattened from the defaults tree.
|
|
80
|
+
# * /skills — the `✗ none` clear entry + the enable/disable verbs +
|
|
81
|
+
# the skill names (#188); after a toggle verb, the names again.
|
|
82
|
+
arg_sources = {
|
|
83
|
+
"skills" => ->(args) { skills_arg_candidates(args) },
|
|
84
|
+
"agents" => ->(args) { agents_arg_candidates(args) },
|
|
85
|
+
"tasks" => ->(args) { agents_arg_candidates(args) },
|
|
86
|
+
"reply" => ->(args) { args.empty? ? blocked_subagent_ids : [] },
|
|
87
|
+
"mcp" => ->(args) { mcp_arg_candidates(args) },
|
|
88
|
+
"mode" => ->(args) { args.empty? ? Rubino::Modes::ALL.map(&:to_s) : [] },
|
|
89
|
+
"model" => ->(args) { args.empty? ? model_arg_candidates : [] },
|
|
90
|
+
"reasoning" => ->(args) { args.empty? ? Rubino::Config::ReasoningPrefs::RENDER_MODES.map(&:to_s) : [] },
|
|
91
|
+
"think" => ->(args) { args.empty? ? Rubino::Config::ReasoningPrefs::EFFORTS.map(&:to_s) : [] },
|
|
92
|
+
"add-dir" => lambda { |args, partial|
|
|
93
|
+
args.empty? ? Rubino::UI::CompletionSource.directory_candidates(partial) : []
|
|
94
|
+
},
|
|
95
|
+
"sessions" => ->(args) { sessions_arg_candidates(args) },
|
|
96
|
+
"memory" => ->(args) { memory_arg_candidates(args) },
|
|
97
|
+
"jobs" => ->(args) { args.empty? ? recent_job_ids : [] },
|
|
98
|
+
"config" => ->(args) { config_arg_candidates(args) }
|
|
99
|
+
}
|
|
100
|
+
Rubino::UI::CompletionSource.new(commands: names, files: files,
|
|
101
|
+
arg_sources: arg_sources,
|
|
102
|
+
descriptions: completion_descriptions)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Argument candidates per /agents position: ids → subcommands → nothing.
|
|
108
|
+
def agents_arg_candidates(args)
|
|
109
|
+
case args.length
|
|
110
|
+
when 0 then Tools::BackgroundTasks.instance.list.map(&:id)
|
|
111
|
+
when 1 then AGENTS_SUBCOMMANDS
|
|
112
|
+
else []
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Children parked on an ask_parent waiting for the human — the ids /reply
|
|
117
|
+
# answers.
|
|
118
|
+
def blocked_subagent_ids
|
|
119
|
+
Tools::BackgroundTasks.instance.awaiting_human.map(&:id)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The /model candidates: the registry's model ids for the provider the
|
|
123
|
+
# next turn would route through. Resolved lazily on each dropdown open so
|
|
124
|
+
# a /model or /config provider switch is reflected immediately.
|
|
125
|
+
def model_arg_candidates
|
|
126
|
+
config = Rubino.configuration
|
|
127
|
+
current = config.model_default
|
|
128
|
+
Rubino::LLM::ModelCatalog.ids_for(
|
|
129
|
+
Rubino::LLM::ProviderResolver.resolve(current, explicit_provider: config.model_provider)
|
|
130
|
+
)
|
|
131
|
+
rescue StandardError
|
|
132
|
+
[]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def mcp_arg_candidates(args)
|
|
136
|
+
case args.length
|
|
137
|
+
when 0 then mcp_server_names + ["reload"]
|
|
138
|
+
when 1 then args.first == "reload" ? [] : MCP_SUBCOMMANDS
|
|
139
|
+
else []
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def mcp_server_names
|
|
144
|
+
(Rubino.configuration.dig("mcp", "servers") || {}).keys.map(&:to_s)
|
|
145
|
+
rescue StandardError
|
|
146
|
+
[]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def sessions_arg_candidates(args)
|
|
150
|
+
case args.length
|
|
151
|
+
when 0 then SESSIONS_SUBCOMMANDS + recent_session_ids
|
|
152
|
+
when 1 then %w[show delete].include?(args.first) ? recent_session_ids : []
|
|
153
|
+
else []
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Recent session ids for the /sessions dropdown — same source the
|
|
158
|
+
# in-chat list reads (Session::Repository#list). Best-effort: a DB
|
|
159
|
+
# hiccup degrades to no id candidates, never a broken prompt.
|
|
160
|
+
def recent_session_ids
|
|
161
|
+
Rubino::Session::Repository.new.list(limit: 10).map { |s| s[:id].to_s }
|
|
162
|
+
rescue StandardError
|
|
163
|
+
[]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def memory_arg_candidates(args)
|
|
167
|
+
case args.length
|
|
168
|
+
when 0 then MEMORY_SUBCOMMANDS
|
|
169
|
+
when 1
|
|
170
|
+
case args.first
|
|
171
|
+
when "show", "forget" then recent_memory_ids
|
|
172
|
+
when "backend" then Rubino::Memory::Backends.names
|
|
173
|
+
else []
|
|
174
|
+
end
|
|
175
|
+
else []
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Recent fact ids (short form) for the /memory show/forget dropdown,
|
|
180
|
+
# read from the ACTIVE backend — the same store /memory manages.
|
|
181
|
+
def recent_memory_ids
|
|
182
|
+
Rubino::Memory::Backends.build.list(limit: 10).map { |m| m[:id].to_s[0..7] }
|
|
183
|
+
rescue StandardError
|
|
184
|
+
[]
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def skills_arg_candidates(args)
|
|
188
|
+
case args.length
|
|
189
|
+
when 0 then [Rubino::UI::CompletionSource::NONE_ENTRY] + SKILLS_SUBCOMMANDS + skill_names
|
|
190
|
+
when 1 then SKILLS_SUBCOMMANDS.include?(args.first) ? skill_names : []
|
|
191
|
+
else []
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# TRUST-aligned skill names (#63), lazily re-read each open so a
|
|
196
|
+
# freshly-authored skill appears. Best-effort, like the other sources.
|
|
197
|
+
def skill_names
|
|
198
|
+
Rubino::Skills::Registry.trusted.names
|
|
199
|
+
rescue StandardError
|
|
200
|
+
[]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Recent job ids (the short form the /jobs table renders — the queue
|
|
204
|
+
# resolves prefixes) for the /jobs dropdown (#187).
|
|
205
|
+
def recent_job_ids
|
|
206
|
+
Rubino::Jobs::Queue.new.list(limit: 10).map { |j| j[:id].to_s[0..7] }
|
|
207
|
+
rescue StandardError
|
|
208
|
+
[]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def config_arg_candidates(args)
|
|
212
|
+
case args.length
|
|
213
|
+
when 0 then CONFIG_SUBCOMMANDS + config_key_candidates
|
|
214
|
+
when 1 then %w[get set].include?(args.first) ? config_key_candidates : []
|
|
215
|
+
else []
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# The KNOWN config vocabulary: every leaf dot-path in the defaults tree
|
|
220
|
+
# (Config::Defaults.to_hash) — the same keys `config get` resolves
|
|
221
|
+
# against. Discovery, not validation: a key only present in the user's
|
|
222
|
+
# config.yml still works typed by hand.
|
|
223
|
+
def config_key_candidates
|
|
224
|
+
flatten_config_keys(Rubino::Config::Defaults.to_hash)
|
|
225
|
+
rescue StandardError
|
|
226
|
+
[]
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def flatten_config_keys(tree, prefix = nil)
|
|
230
|
+
tree.flat_map do |key, value|
|
|
231
|
+
path = [prefix, key.to_s].compact.join(".")
|
|
232
|
+
value.is_a?(Hash) && !value.empty? ? flatten_config_keys(value, path) : [path]
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# One-line descriptions for the dropdown (#39): the SAME strings /help
|
|
237
|
+
# shows (BuiltIns + custom command frontmatter), plus usage hints for the
|
|
238
|
+
# /agents subcommand grammar. Best-effort — a loader hiccup degrades to
|
|
239
|
+
# built-ins only, never breaks the prompt.
|
|
240
|
+
def completion_descriptions
|
|
241
|
+
descriptions = ::Rubino::Commands::BuiltIns::DESCRIPTIONS.dup
|
|
242
|
+
begin
|
|
243
|
+
@cmd_loader.all.each do |cmd|
|
|
244
|
+
desc = cmd.description.to_s.strip
|
|
245
|
+
descriptions["/#{cmd.name}"] = desc unless desc.empty?
|
|
246
|
+
end
|
|
247
|
+
rescue StandardError
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
descriptions.merge(
|
|
251
|
+
"steer" => "park a note the subagent folds in at its next turn",
|
|
252
|
+
"probe" => "ask the subagent an ephemeral question (not saved)",
|
|
253
|
+
"--stop" => "cancel the running subagent",
|
|
254
|
+
# /mcp verbs (#182). "off" is ALSO /think's zero effort (#185) —
|
|
255
|
+
# descriptions are keyed by candidate string, so the one line
|
|
256
|
+
# covers both surfaces.
|
|
257
|
+
"reload" => "re-read config.yml and reconnect every MCP server",
|
|
258
|
+
"on" => "(re)start the MCP server and register its tools",
|
|
259
|
+
"off" => "mcp: stop the server and its tools · think: no thinking budget",
|
|
260
|
+
# /sessions + /memory verbs (#183/#184). "show"/"--all" are shared
|
|
261
|
+
# by both grammars — and "show" by /config too (#187) — so each
|
|
262
|
+
# one-liner covers all its surfaces.
|
|
263
|
+
"show" => "show full details (sessions/memory: by id · config: the whole tree)",
|
|
264
|
+
"delete" => "delete a session and its messages (asks to confirm)",
|
|
265
|
+
"search" => "search facts by substring",
|
|
266
|
+
"forget" => "delete a fact by id",
|
|
267
|
+
"backend" => "show the active memory backend",
|
|
268
|
+
"--all" => "list everything (sessions: no row cap · memory: incl. retired)",
|
|
269
|
+
# /config verbs (#187) + /skills toggle verbs (#188).
|
|
270
|
+
"get" => "read one config value (dot-notation, merged over defaults)",
|
|
271
|
+
"set" => "write one config value (persisted to config.yml)",
|
|
272
|
+
"path" => "print the config file path",
|
|
273
|
+
"enable" => "put a skill back in the index (every session)",
|
|
274
|
+
"disable" => "drop a skill from the index (every session, persisted)",
|
|
275
|
+
# The closed enums (#185) reuse the same wording the commands print.
|
|
276
|
+
"default" => Rubino::Modes.description(:default),
|
|
277
|
+
"plan" => Rubino::Modes.description(:plan),
|
|
278
|
+
"yolo" => Rubino::Modes.description(:yolo),
|
|
279
|
+
"hidden" => "show no reasoning (Ctrl-O reveals the last)",
|
|
280
|
+
"collapsed" => "a dim one-line cue; Ctrl-O expands",
|
|
281
|
+
"full" => "the whole reasoning as a dim aside",
|
|
282
|
+
"low" => "small thinking-token budget",
|
|
283
|
+
"medium" => "medium thinking-token budget (default)",
|
|
284
|
+
"high" => "large thinking-token budget"
|
|
285
|
+
)
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module CLI
|
|
5
|
+
module Chat
|
|
6
|
+
# Hosts the collapsed background-subagent card region (F1) at the IDLE
|
|
7
|
+
# prompt, extracted from ChatCommand (#17): repaints the registry's live
|
|
8
|
+
# snapshot onto whatever BottomComposer currently owns the screen, and
|
|
9
|
+
# owns the low-frequency ticker thread that keeps the cards fresh in the
|
|
10
|
+
# quiet gaps between child events.
|
|
11
|
+
class IdleCardHost
|
|
12
|
+
# How often (seconds) the idle card region repaints on its own so the
|
|
13
|
+
# cards' elapsed-time field advances even when no child event fires, and so
|
|
14
|
+
# we promptly notice the last child finishing. Child tool start/finish
|
|
15
|
+
# already poke an immediate repaint via #set_subagent_cards; this tick only
|
|
16
|
+
# covers the quiet gaps.
|
|
17
|
+
IDLE_CARD_TICK = 1.0
|
|
18
|
+
|
|
19
|
+
# True when at least one background subagent (the `task` tool's default)
|
|
20
|
+
# is still live — running or parked on a human approval. Drives whether the
|
|
21
|
+
# idle prompt hosts the collapsed live cards (F1).
|
|
22
|
+
def children_live?
|
|
23
|
+
Tools::BackgroundTasks.instance.running.any?
|
|
24
|
+
rescue StandardError
|
|
25
|
+
false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Repaints the idle card region from the registry's current snapshot. Mirrors
|
|
29
|
+
# UI::CLI#set_subagent_cards (which the child taps call), but is callable
|
|
30
|
+
# from the REPL's own ticker without a parent UI handle — both ultimately
|
|
31
|
+
# drive BottomComposer#set_cards under the render mutex.
|
|
32
|
+
def paint
|
|
33
|
+
composer = UI::BottomComposer.current
|
|
34
|
+
return unless composer
|
|
35
|
+
|
|
36
|
+
entries = Tools::BackgroundTasks.instance.running
|
|
37
|
+
composer.set_cards(cards.card_lines(entries))
|
|
38
|
+
rescue StandardError
|
|
39
|
+
nil # a card repaint is cosmetic — never break the idle prompt.
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A low-frequency ticker that repaints the idle card region so the elapsed
|
|
43
|
+
# time advances and a finished last-child is noticed even in a quiet gap
|
|
44
|
+
# between child events. Repaints go through the composer's render mutex, so
|
|
45
|
+
# they never race the keystroke handler. Exits as soon as no child is live
|
|
46
|
+
# (it clears the region one last time) or when killed on teardown.
|
|
47
|
+
def start_ticker(composer)
|
|
48
|
+
Thread.new do
|
|
49
|
+
loop do
|
|
50
|
+
sleep(IDLE_CARD_TICK)
|
|
51
|
+
break unless composer.equal?(UI::BottomComposer.current)
|
|
52
|
+
|
|
53
|
+
paint
|
|
54
|
+
break unless children_live?
|
|
55
|
+
end
|
|
56
|
+
rescue StandardError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def cards
|
|
64
|
+
@cards ||= UI::SubagentCards.new
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module CLI
|
|
5
|
+
module Chat
|
|
6
|
+
# The REPL's image-attachment inbox (attach an image from the terminal),
|
|
7
|
+
# extracted from ChatCommand (#17).
|
|
8
|
+
#
|
|
9
|
+
# Attachments live in #pending_image_paths between the prompt read and
|
|
10
|
+
# the turn; run_turn consumes + clears them via #take! so each image is
|
|
11
|
+
# sent once into the native vision slot (image_paths →
|
|
12
|
+
# Lifecycle#execute → adapter `with:`).
|
|
13
|
+
class ImageInbox
|
|
14
|
+
# Builds the [text, image_paths] pair for a one-shot turn. Pulls @image /
|
|
15
|
+
# dropped-path tokens out of the prompt (so they hit the vision slot, not
|
|
16
|
+
# the literal text) and prepends any paths given via --image. Flag paths
|
|
17
|
+
# are expanded the same way as in-line tokens; a flag path that isn't a
|
|
18
|
+
# readable image is reported and skipped rather than silently dropped.
|
|
19
|
+
#
|
|
20
|
+
# Every candidate then passes the SAME secure-by-default attachment gate
|
|
21
|
+
# as the server/run path (Attachments::Classify + Policy, via
|
|
22
|
+
# ImageInput#attachment_error) — a policy rejection is a clean one-line
|
|
23
|
+
# error BEFORE any network call, not five provider retries (#98).
|
|
24
|
+
def self.resolve_oneshot(query, flag_values)
|
|
25
|
+
flag_paths = Array(flag_values).map { |p| Interaction::ImageInput.expand(p) }
|
|
26
|
+
flag_paths.each do |p|
|
|
27
|
+
next if LLM::ContentBuilder.image_file?(p) && File.file?(p)
|
|
28
|
+
|
|
29
|
+
warn "rubino: ignoring --image #{p} (not a readable image file)"
|
|
30
|
+
end
|
|
31
|
+
valid_flags = flag_paths.select { |p| LLM::ContentBuilder.image_file?(p) && File.file?(p) }
|
|
32
|
+
valid_flags.each do |p|
|
|
33
|
+
reason = Interaction::ImageInput.attachment_error(p)
|
|
34
|
+
raise Rubino::Error, "--image #{p}: #{reason}" if reason
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
result = Interaction::ImageInput.parse(query, existing: valid_flags)
|
|
38
|
+
if (rejection = result.rejected.first)
|
|
39
|
+
raise Rubino::Error, "#{rejection[:path]}: #{rejection[:reason]}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
[result.text, result.image_paths]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def pending_image_paths
|
|
46
|
+
@pending_image_paths ||= []
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Consumes the turn's queued image attachments (the native vision slot)
|
|
50
|
+
# and resets so they're attached exactly once, not re-sent next turn.
|
|
51
|
+
def take!
|
|
52
|
+
paths = pending_image_paths
|
|
53
|
+
@pending_image_paths = []
|
|
54
|
+
paths
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Seeds the interactive pending-images inbox from --image/-i flag paths
|
|
58
|
+
# (#160), through the SAME attachment gate every other staging surface
|
|
59
|
+
# uses (Attachments::Classify + Policy via ImageInput#attachment_error).
|
|
60
|
+
# A bad flag path warns and is skipped — interactive startup must not die
|
|
61
|
+
# on it the way one-shot raises. Staged images show the usual indicator
|
|
62
|
+
# and are covered by /clear-images, as documented.
|
|
63
|
+
def stage_flag_images(flag_values, ui)
|
|
64
|
+
Array(flag_values).each do |raw|
|
|
65
|
+
path = Interaction::ImageInput.expand(raw)
|
|
66
|
+
unless LLM::ContentBuilder.image_file?(path) && File.file?(path)
|
|
67
|
+
ui.warning("not attached — #{raw}: not a readable image file")
|
|
68
|
+
next
|
|
69
|
+
end
|
|
70
|
+
if (reason = Interaction::ImageInput.attachment_error(path))
|
|
71
|
+
ui.warning("not attached — #{File.basename(path)}: #{reason}")
|
|
72
|
+
next
|
|
73
|
+
end
|
|
74
|
+
pending_image_paths << path unless pending_image_paths.include?(path)
|
|
75
|
+
end
|
|
76
|
+
show_image_indicator(ui, pending_image_paths) unless pending_image_paths.empty?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Parses the line for image references (@image, dropped/quoted/escaped
|
|
80
|
+
# path), moves any into @pending_image_paths and returns the cleaned text.
|
|
81
|
+
# Non-image references are left in the text (current behaviour). Shows an
|
|
82
|
+
# in-prompt indicator for whatever is now attached. A candidate the
|
|
83
|
+
# attachment policy rejects (oversize / spoofed extension / unsafe) is
|
|
84
|
+
# dropped with a one-line warning instead of being shipped (#98).
|
|
85
|
+
def extract_images!(input, ui)
|
|
86
|
+
result = Interaction::ImageInput.parse(input, existing: pending_image_paths)
|
|
87
|
+
result.rejected.each do |rejection|
|
|
88
|
+
ui.warning("not attached — #{File.basename(rejection[:path])}: #{rejection[:reason]}")
|
|
89
|
+
end
|
|
90
|
+
newly = result.image_paths - pending_image_paths
|
|
91
|
+
@pending_image_paths = result.image_paths
|
|
92
|
+
# A line with text AND an @image sends BOTH on THIS turn (the cleaned
|
|
93
|
+
# text is non-empty, so the main loop submits now); an image-only line
|
|
94
|
+
# stages for the next message. The indicator must match that
|
|
95
|
+
# disposition — saying "sent with your next message" on a text+image
|
|
96
|
+
# line is wrong (#225).
|
|
97
|
+
unless newly.empty?
|
|
98
|
+
attached_now = !result.text.strip.empty?
|
|
99
|
+
show_image_indicator(ui, newly, attached_now: attached_now)
|
|
100
|
+
end
|
|
101
|
+
result.text
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Handles the REPL-local image commands. Returns true when it consumed the
|
|
105
|
+
# input (so the main loop should `next`), false otherwise.
|
|
106
|
+
#
|
|
107
|
+
# /paste — grab an image from the clipboard into image_paths
|
|
108
|
+
# /clear-images — drop all pending attachments
|
|
109
|
+
# rubocop:disable Naming/PredicateMethod -- "did I consume the line", not a pure predicate
|
|
110
|
+
def handle_image_command(input, ui)
|
|
111
|
+
case input.strip.downcase
|
|
112
|
+
when "/clear-images", "/clear-image"
|
|
113
|
+
if pending_image_paths.empty?
|
|
114
|
+
ui.info("No attached images to clear.")
|
|
115
|
+
else
|
|
116
|
+
ui.info("Cleared #{pending_image_paths.size} attached image(s).")
|
|
117
|
+
@pending_image_paths = []
|
|
118
|
+
end
|
|
119
|
+
true
|
|
120
|
+
when "/paste"
|
|
121
|
+
paste_clipboard_image(ui)
|
|
122
|
+
true
|
|
123
|
+
else
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
# rubocop:enable Naming/PredicateMethod
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def paste_clipboard_image(ui)
|
|
132
|
+
path = Interaction::ClipboardImage.save_to_tempfile
|
|
133
|
+
unless path
|
|
134
|
+
ui.warning("Clipboard paste failed: #{Interaction::ClipboardImage.unavailable_reason}")
|
|
135
|
+
return
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Same universal attachment gate as @image/dropped/--image paths (#98):
|
|
139
|
+
# a clipboard capture that violates policy (e.g. oversize) is dropped
|
|
140
|
+
# with a clear warning, never shipped to the provider.
|
|
141
|
+
if (reason = Interaction::ImageInput.attachment_error(path))
|
|
142
|
+
ui.warning("not attached — #{File.basename(path)}: #{reason}")
|
|
143
|
+
return
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
pending_image_paths << path unless pending_image_paths.include?(path)
|
|
147
|
+
show_image_indicator(ui, [path])
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# In-prompt indicator of attached image(s), Claude-Code style. When the
|
|
151
|
+
# image rides a line that ALSO carries text (+attached_now+), it goes out
|
|
152
|
+
# with THIS turn, so the indicator says so; an image-only line stages for
|
|
153
|
+
# the next message and keeps the "sent with your next message" wording
|
|
154
|
+
# (#225).
|
|
155
|
+
def show_image_indicator(ui, newly, attached_now: false)
|
|
156
|
+
newly.each { |p| ui.status("[image: #{File.basename(p)}]") }
|
|
157
|
+
total = pending_image_paths.size
|
|
158
|
+
disposition = if attached_now
|
|
159
|
+
"attached to this message"
|
|
160
|
+
else
|
|
161
|
+
"sent with your next message (/clear-images to drop)"
|
|
162
|
+
end
|
|
163
|
+
ui.status("#{total} image#{"s" if total != 1} attached — #{disposition}.")
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|