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,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Commands
|
|
5
|
+
module Handlers
|
|
6
|
+
# The `/help` and `/commands` listings (and the unknown-command
|
|
7
|
+
# "Available:" roster), extracted from Commands::Executor (batch B). A plain
|
|
8
|
+
# collaborator given the command `loader` and the `ui` — it owns the
|
|
9
|
+
# built-in/keys/input reference text and the custom-command discovery copy.
|
|
10
|
+
class Help
|
|
11
|
+
def initialize(ui:, loader:)
|
|
12
|
+
@ui = ui
|
|
13
|
+
@loader = loader
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# All known slash commands (built-ins + discovered custom), used for the
|
|
17
|
+
# "Available:" hint on an unknown command (L6 — previously listed only
|
|
18
|
+
# custom commands, which is usually empty).
|
|
19
|
+
def available_commands
|
|
20
|
+
custom = begin
|
|
21
|
+
@loader.names
|
|
22
|
+
rescue StandardError
|
|
23
|
+
[]
|
|
24
|
+
end
|
|
25
|
+
(BuiltIns::NAMES + custom).uniq
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def show_help
|
|
29
|
+
@ui.info("Slash commands run actions or reusable prompts. Type /<name>; /help is this list.")
|
|
30
|
+
@ui.blank_line
|
|
31
|
+
@ui.info("Built-in:")
|
|
32
|
+
rows = help_builtin_rows
|
|
33
|
+
width = rows.map { |name, _| name.length }.max
|
|
34
|
+
rows.each do |name, desc|
|
|
35
|
+
@ui.info(" #{name.ljust(width)} - #{desc}")
|
|
36
|
+
end
|
|
37
|
+
@ui.blank_line
|
|
38
|
+
|
|
39
|
+
# The `@` file-picker is a discoverable composer feature (type `@` to
|
|
40
|
+
# autocomplete a workspace file) but was undocumented in /help (F14).
|
|
41
|
+
# /paste and /clear-images already appear once under "Built-in" above,
|
|
42
|
+
# so they're NOT repeated here — this section is image/file INPUT only,
|
|
43
|
+
# no command rows (#87 de-dup).
|
|
44
|
+
@ui.info("Input:")
|
|
45
|
+
@ui.info(" ! <command> - run a shell command yourself, no approval; output joins the context")
|
|
46
|
+
@ui.info(" @<path> - autocomplete a workspace file into the prompt")
|
|
47
|
+
@ui.info(" @<image> - attach an image (png/jpg/jpeg/gif/webp/bmp) to the turn")
|
|
48
|
+
@ui.info(" <image path> - drop or paste an image file path to attach it")
|
|
49
|
+
@ui.blank_line
|
|
50
|
+
|
|
51
|
+
# The keystroke vocabulary was invisible in /help (#87): a newcomer
|
|
52
|
+
# couldn't learn how to cancel a turn, drive the approval menu, or that
|
|
53
|
+
# Tab completes. One compact reference line covers it.
|
|
54
|
+
@ui.info("Keys:")
|
|
55
|
+
@ui.info(" ↑/↓ + Enter - choose in the approval menu")
|
|
56
|
+
@ui.info(" Enter - send; during a turn, interrupt it and run this next")
|
|
57
|
+
@ui.info(" Alt-Enter - queue this to run after the current turn (or /queued <msg>)")
|
|
58
|
+
@ui.info(" Shift-Tab - cycle mode (default → plan → yolo)")
|
|
59
|
+
@ui.info(" Ctrl-O - reveal the last reasoning (collapsed or hidden)")
|
|
60
|
+
@ui.info(" Ctrl-C - cancel the turn (twice to exit)")
|
|
61
|
+
@ui.info(" Esc Esc - rewind to an earlier message (fork + edit & resend)")
|
|
62
|
+
@ui.info(" Tab - complete the highlighted /command or @file")
|
|
63
|
+
@ui.info(" / - start a command; @ attach a file/image")
|
|
64
|
+
@ui.blank_line
|
|
65
|
+
|
|
66
|
+
custom = @loader.all
|
|
67
|
+
if custom.any?
|
|
68
|
+
@ui.info("Custom commands (run with /<name>; add --preview to see the prompt first):")
|
|
69
|
+
custom.each do |cmd|
|
|
70
|
+
@ui.info(" /#{cmd.name}#{custom_desc(cmd)}")
|
|
71
|
+
end
|
|
72
|
+
else
|
|
73
|
+
@ui.info("Custom commands (none yet — run /commands to learn how to add one)")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def show_commands
|
|
78
|
+
commands = @loader.all
|
|
79
|
+
return explain_empty_commands if commands.empty?
|
|
80
|
+
|
|
81
|
+
@ui.info("Custom commands (run with /<name>; add --preview to see the prompt first):")
|
|
82
|
+
commands.each do |cmd|
|
|
83
|
+
@ui.info(" /#{cmd.name}#{custom_desc(cmd)}")
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
# The Built-in rows for /help, with synonyms collapsed so /help never
|
|
90
|
+
# shows two rows that say the same thing (#87): /exit and /quit share one
|
|
91
|
+
# "End session" row as "/exit, /quit". Everything else passes through in
|
|
92
|
+
# the BuiltIns order.
|
|
93
|
+
def help_builtin_rows
|
|
94
|
+
rows = []
|
|
95
|
+
seen = {}
|
|
96
|
+
BuiltIns::DESCRIPTIONS.each do |name, desc|
|
|
97
|
+
if (canonical = seen[desc])
|
|
98
|
+
rows[canonical[:index]][0] = "#{canonical[:name]}, #{name}"
|
|
99
|
+
else
|
|
100
|
+
seen[desc] = { index: rows.length, name: name }
|
|
101
|
+
rows << [name, desc]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
rows
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The cryptic old empty-state ("Add .md files to .rubino/commands/")
|
|
108
|
+
# named a dir without ever explaining what a command IS. Now we explain
|
|
109
|
+
# the concept, name the REAL configured paths, and show a concrete example.
|
|
110
|
+
def explain_empty_commands
|
|
111
|
+
@ui.info("Custom commands are reusable prompts you trigger with a slash. Each is a")
|
|
112
|
+
@ui.info("Markdown file in a commands directory; the file body becomes the prompt")
|
|
113
|
+
@ui.info("($ARGUMENTS / $1..$9 expand to what you type after the command).")
|
|
114
|
+
@ui.blank_line
|
|
115
|
+
@ui.info("No custom commands found yet.")
|
|
116
|
+
@ui.blank_line
|
|
117
|
+
@ui.info("Searched: #{command_dirs.join(", ")}")
|
|
118
|
+
@ui.info("Create one, e.g. .rubino/commands/review.md:")
|
|
119
|
+
@ui.blank_line
|
|
120
|
+
@ui.info(" ---")
|
|
121
|
+
@ui.info(" description: Review the current diff for bugs")
|
|
122
|
+
@ui.info(" ---")
|
|
123
|
+
@ui.info(" Review the staged diff. Flag correctness bugs only. $ARGUMENTS")
|
|
124
|
+
@ui.blank_line
|
|
125
|
+
@ui.info("Then run: /review focus on the auth change")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The directories the loader actually searches, for the empty-state copy.
|
|
129
|
+
# Resolves through Loader.resolve_path so the "Searched:" line reports the
|
|
130
|
+
# real paths (RUBINO_HOME-aware), not a literal ~/.rubino never searched.
|
|
131
|
+
def command_dirs
|
|
132
|
+
paths = Rubino.configuration.dig("commands", "paths")
|
|
133
|
+
paths = Rubino::Config::Defaults.to_hash.dig("commands", "paths") if paths.nil?
|
|
134
|
+
Array(paths).map { |dir| Loader.resolve_path(dir) }
|
|
135
|
+
rescue StandardError
|
|
136
|
+
Loader.default_command_paths
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# " - <description>" suffix for a custom-command listing, omitted when the
|
|
140
|
+
# command carries no description so the line stays clean.
|
|
141
|
+
def custom_desc(cmd)
|
|
142
|
+
desc = cmd.description.to_s.strip
|
|
143
|
+
desc.empty? ? "" : " - #{desc}"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Commands
|
|
5
|
+
module Handlers
|
|
6
|
+
# The `/jobs` in-chat window into the PERSISTENT jobs queue (#187),
|
|
7
|
+
# extracted from Commands::Executor (batch B) — the queue the agent itself
|
|
8
|
+
# feeds mid-session (DistillSkillJob after tool-heavy turns, memory
|
|
9
|
+
# extraction), distinct from the in-process /agents subagents. Read-mostly:
|
|
10
|
+
# `process`/`worker` stay CLI-only (they are daemons, not session actions).
|
|
11
|
+
#
|
|
12
|
+
# /jobs → status counts + the recent-jobs table (the SAME
|
|
13
|
+
# rendering as `rubino jobs list` — JobsCommand.render_list)
|
|
14
|
+
# /jobs <id> → one job in full (attempts, payload, last error);
|
|
15
|
+
# short-id prefixes resolve, like /memory show
|
|
16
|
+
class Jobs
|
|
17
|
+
# Render order for the /jobs counts header (#187) — lifecycle order, not
|
|
18
|
+
# the arbitrary GROUP BY order (any unknown status is appended).
|
|
19
|
+
STATUS_ORDER = %w[queued running completed failed dead].freeze
|
|
20
|
+
|
|
21
|
+
def initialize(ui:)
|
|
22
|
+
@ui = ui
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def handle_jobs(arguments)
|
|
26
|
+
id = arguments.to_s.strip.split(/\s+/).first
|
|
27
|
+
id.nil? ? show_jobs_list : show_job_detail(id)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def show_jobs_list
|
|
33
|
+
queue = Rubino::Jobs::Queue.new
|
|
34
|
+
counts = queue.counts
|
|
35
|
+
if counts.empty?
|
|
36
|
+
@ui.info("No jobs yet — the agent enqueues background work " \
|
|
37
|
+
"(skill distillation, memory extraction) as you chat.")
|
|
38
|
+
return
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
ordered = (STATUS_ORDER & counts.keys) + (counts.keys - STATUS_ORDER)
|
|
42
|
+
@ui.info(ordered.map { |status| "#{counts[status]} #{status}" }.join(" · "))
|
|
43
|
+
CLI::JobsCommand.render_list(queue.list, ui: @ui)
|
|
44
|
+
@ui.info("/jobs <id> for detail · `rubino jobs process` runs pending ones now")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def show_job_detail(id)
|
|
48
|
+
job = Rubino::Jobs::Queue.new.find(id)
|
|
49
|
+
if job.nil?
|
|
50
|
+
@ui.error("no job with id #{id}.")
|
|
51
|
+
@ui.info("List them with /jobs")
|
|
52
|
+
return
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@ui.info("#{job[:id][0..7]} #{job[:type]} · #{job[:status]}")
|
|
56
|
+
@ui.info(" attempts #{job[:attempts]}/#{job[:max_attempts]}")
|
|
57
|
+
@ui.info(" run_at #{job[:run_at]}")
|
|
58
|
+
@ui.info(" created #{job[:created_at]}")
|
|
59
|
+
@ui.info(" payload #{truncate(job[:payload_json], 200)}")
|
|
60
|
+
error = job[:last_error].to_s
|
|
61
|
+
@ui.error(error) unless error.empty?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def truncate(text, max)
|
|
65
|
+
s = text.to_s.gsub(/\s+/, " ").strip
|
|
66
|
+
s.length > max ? "#{s[0, max - 1]}…" : s
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Commands
|
|
7
|
+
module Handlers
|
|
8
|
+
# The `/mcp` in-chat management of MCP servers (#182), extracted from
|
|
9
|
+
# Commands::Executor (batch B). Shaped like /skills:
|
|
10
|
+
#
|
|
11
|
+
# /mcp → server list: status, transport, tool count
|
|
12
|
+
# /mcp <server> → drill-in: transport/target, health, its tools
|
|
13
|
+
# /mcp <server> off → stop the client + deregister its tools (session)
|
|
14
|
+
# /mcp <server> on → (re)start the client + register its tools
|
|
15
|
+
# /mcp reload → re-read config.yml and reconnect every server
|
|
16
|
+
#
|
|
17
|
+
# List/drill-in read the LIVE booted manager (Rubino::MCP.manager) and
|
|
18
|
+
# never re-spawn stdio servers — doctor's start/stop dance is wrong inside
|
|
19
|
+
# a session that already holds clients. `off` is session-scoped, like
|
|
20
|
+
# /skills activation; persistent disable stays a config edit (mcp.enabled
|
|
21
|
+
# or removing the server).
|
|
22
|
+
class MCP
|
|
23
|
+
def initialize(ui:)
|
|
24
|
+
@ui = ui
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def handle_mcp(arguments)
|
|
28
|
+
server, action = arguments.to_s.strip.split(/\s+/)
|
|
29
|
+
# reload must work BEFORE the enabled? gate: its whole point is picking
|
|
30
|
+
# up a config edit (e.g. a first server added mid-session).
|
|
31
|
+
return reload_mcp if server == "reload"
|
|
32
|
+
|
|
33
|
+
unless Rubino::MCP.enabled?
|
|
34
|
+
show_mcp_empty_state
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
server.nil? ? show_mcp_list : handle_mcp_server(server, action)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# The two empty states the issue calls out: no servers at all vs the
|
|
44
|
+
# mcp.enabled kill switch.
|
|
45
|
+
# Empty states are quiet facts, not successes/calls-to-celebrate:
|
|
46
|
+
# dim, never colored (P8).
|
|
47
|
+
def show_mcp_empty_state
|
|
48
|
+
if mcp_servers_config.any?
|
|
49
|
+
@ui.status("MCP is disabled (mcp.enabled: false in config.yml) — " \
|
|
50
|
+
"#{mcp_servers_config.size} server(s) defined but not started.")
|
|
51
|
+
else
|
|
52
|
+
@ui.status("No MCP servers configured.")
|
|
53
|
+
@ui.status("Add an mcp.servers block to config.yml (see docs/mcp.md), then /mcp reload.")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def show_mcp_list
|
|
58
|
+
mcp_servers_config.each do |name, server_config|
|
|
59
|
+
tools = mcp_tools_for(name).size
|
|
60
|
+
@ui.panel_line(name, "(#{server_config["transport"] || "stdio"}) " \
|
|
61
|
+
"#{mcp_status_icon(name)} · #{tools} tool#{"s" if tools != 1}")
|
|
62
|
+
end
|
|
63
|
+
@ui.status("/mcp <server> for its tools · /mcp <server> on|off · /mcp reload")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def handle_mcp_server(name, action)
|
|
67
|
+
unless mcp_servers_config.key?(name)
|
|
68
|
+
@ui.error("unknown MCP server: #{name}")
|
|
69
|
+
@ui.info("Configured: #{mcp_servers_config.keys.join(", ")}")
|
|
70
|
+
return
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
case action
|
|
74
|
+
when nil then show_mcp_server(name)
|
|
75
|
+
when "off" then mcp_server_off(name)
|
|
76
|
+
when "on" then mcp_server_on(name)
|
|
77
|
+
else
|
|
78
|
+
@ui.error("unknown /mcp action: #{action}")
|
|
79
|
+
@ui.info("Usage: /mcp #{name} [on|off]")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def show_mcp_server(name)
|
|
84
|
+
server_config = mcp_servers_config[name]
|
|
85
|
+
transport = server_config["transport"] || "stdio"
|
|
86
|
+
target = if transport == "stdio"
|
|
87
|
+
[server_config["command"], *Array(server_config["args"])].join(" ")
|
|
88
|
+
else
|
|
89
|
+
server_config["url"].to_s
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
@ui.info("#{name} #{mcp_status_icon(name)}")
|
|
93
|
+
@ui.panel_line("transport", "#{transport} · #{target}")
|
|
94
|
+
last_error = Rubino::MCP.manager&.last_errors&.dig(name)
|
|
95
|
+
@ui.panel_line("last error", last_error) if last_error
|
|
96
|
+
show_mcp_server_tools(name)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The server's registered tools (prefixed names + descriptions), wrapped
|
|
100
|
+
# like the /skills list so long descriptions never hard-break mid-word.
|
|
101
|
+
def show_mcp_server_tools(name)
|
|
102
|
+
tools = mcp_tools_for(name)
|
|
103
|
+
if tools.empty?
|
|
104
|
+
@ui.info(" tools (none registered — /mcp #{name} on to start it)")
|
|
105
|
+
return
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
@ui.info(" tools #{tools.size}:")
|
|
109
|
+
tools.each do |tool|
|
|
110
|
+
wrap_skill_line(" #{tool.name} - ", tool.description.to_s).each { |line| @ui.info(line) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Session-scoped disable: stop the client AND drop its wrappers from the
|
|
115
|
+
# registry (Manager#stop_server deregisters — #182), so the model stops
|
|
116
|
+
# seeing tools whose client is gone.
|
|
117
|
+
def mcp_server_off(name)
|
|
118
|
+
manager = Rubino::MCP.manager
|
|
119
|
+
if manager.nil? || !manager.clients.key?(name)
|
|
120
|
+
@ui.info("MCP server #{name} is not running.")
|
|
121
|
+
return
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
removed = mcp_tools_for(name).size
|
|
125
|
+
manager.stop_server(name)
|
|
126
|
+
@ui.success("MCP server #{name} stopped — #{removed} tool#{"s" if removed != 1} removed " \
|
|
127
|
+
"for this session (/mcp #{name} on to restart; config untouched).")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# (Re)start one server and register its tools. With no booted manager yet
|
|
131
|
+
# (MCP never enabled at boot, or boot failed), boot! brings the whole
|
|
132
|
+
# subsystem up — which starts this server too.
|
|
133
|
+
def mcp_server_on(name)
|
|
134
|
+
manager = Rubino::MCP.manager || Rubino::MCP.boot!
|
|
135
|
+
unless manager
|
|
136
|
+
@ui.error("could not boot MCP — check mcp.servers in config.yml, or /mcp reload")
|
|
137
|
+
return
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
manager.stop_server(name) if manager.clients.key?(name)
|
|
141
|
+
# start_server already warned with the failure detail; just point at it.
|
|
142
|
+
return @ui.error("could not start MCP server #{name} (see warning above)") unless
|
|
143
|
+
manager.start_server(name, mcp_servers_config[name])
|
|
144
|
+
|
|
145
|
+
manager.register_server_tools(name)
|
|
146
|
+
count = mcp_tools_for(name).size
|
|
147
|
+
@ui.success("MCP server #{name} started — #{count} tool#{"s" if count != 1} registered.")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def reload_mcp
|
|
151
|
+
manager = Rubino::MCP.reload!
|
|
152
|
+
if manager.nil?
|
|
153
|
+
show_mcp_empty_state
|
|
154
|
+
return
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
@ui.success("MCP reloaded.")
|
|
158
|
+
show_mcp_list
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# `<glyph> <word>` for a server's state (colored like agent_status_icon):
|
|
162
|
+
# green ● reachable, red ✗ down, yellow ◌ not started (no live client).
|
|
163
|
+
def mcp_status_icon(name)
|
|
164
|
+
entry = mcp_health.find { |h| h[:name] == name }
|
|
165
|
+
glyph, word, color =
|
|
166
|
+
if entry.nil? then ["◌", "not started", :yellow]
|
|
167
|
+
elsif entry[:alive] then ["●", "reachable", :green]
|
|
168
|
+
else ["✗", "down", :red]
|
|
169
|
+
end
|
|
170
|
+
"#{pastel.public_send(color, glyph)} #{word}"
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The configured mcp.servers block (name => config), {} when absent.
|
|
174
|
+
def mcp_servers_config
|
|
175
|
+
Rubino.configuration.dig("mcp", "servers") || {}
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Live reachability from the booted manager; [] when MCP never booted.
|
|
179
|
+
# Manager#health_check already rescues per client, so a wedged transport
|
|
180
|
+
# reports alive: false instead of raising.
|
|
181
|
+
def mcp_health
|
|
182
|
+
Rubino::MCP.manager&.health_check || []
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# The registry wrappers a server contributed (prefixed tools).
|
|
186
|
+
def mcp_tools_for(server_name)
|
|
187
|
+
Tools::Registry.all.select do |tool|
|
|
188
|
+
tool.is_a?(Rubino::MCP::MCPToolWrapper) && tool.server_name == server_name
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Wraps "<head><description>" to the terminal width, breaking only on
|
|
193
|
+
# whitespace, with continuation lines indented to the description column.
|
|
194
|
+
def wrap_skill_line(head, description)
|
|
195
|
+
width = terminal_width
|
|
196
|
+
indent = " " * head.length
|
|
197
|
+
avail = [width - head.length, 20].max
|
|
198
|
+
|
|
199
|
+
lines = []
|
|
200
|
+
current = +""
|
|
201
|
+
description.split(/\s+/).each do |word|
|
|
202
|
+
candidate = current.empty? ? word : "#{current} #{word}"
|
|
203
|
+
if candidate.length > avail && !current.empty?
|
|
204
|
+
lines << current
|
|
205
|
+
current = word.dup
|
|
206
|
+
else
|
|
207
|
+
current = candidate
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
lines << current unless current.empty?
|
|
211
|
+
lines = [""] if lines.empty?
|
|
212
|
+
|
|
213
|
+
lines.each_with_index.map { |line, i| (i.zero? ? head : indent) + line }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def pastel
|
|
217
|
+
@pastel ||= Pastel.new
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def terminal_width
|
|
221
|
+
cols = IO.console&.winsize&.last
|
|
222
|
+
cols&.positive? ? cols : 80
|
|
223
|
+
rescue StandardError
|
|
224
|
+
80
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Commands
|
|
5
|
+
module Handlers
|
|
6
|
+
# The `/memory` in-chat read/manage view over the *active* memory backend,
|
|
7
|
+
# extracted from Commands::Executor (batch B) — the same store the agent
|
|
8
|
+
# loop, the `rubino memory` CLI (#94) and the HTTP `/v1/memory` ops resolve
|
|
9
|
+
# via `Memory::Backends.build`. The agent's MemoryTool does autonomous
|
|
10
|
+
# writes; this is the human's window into it.
|
|
11
|
+
#
|
|
12
|
+
# /memory → backend + count + recent facts
|
|
13
|
+
# /memory --all → recent facts INCLUDING retired, marked (#184)
|
|
14
|
+
# /memory <query> → substring search over content
|
|
15
|
+
# /memory search <query> → same search, explicit subcommand
|
|
16
|
+
# /memory show <id> → one fact in full, with the temporal chain (#184)
|
|
17
|
+
# /memory forget <id> → delete a fact
|
|
18
|
+
# /memory backend → active + available backends (#184)
|
|
19
|
+
class Memory
|
|
20
|
+
def initialize(ui:)
|
|
21
|
+
@ui = ui
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def handle_memory(arguments)
|
|
25
|
+
args = arguments.to_s.strip
|
|
26
|
+
|
|
27
|
+
if args.empty?
|
|
28
|
+
show_memory_summary
|
|
29
|
+
elsif args == "--all"
|
|
30
|
+
show_memory_summary(include_retired: true)
|
|
31
|
+
elsif args.match?(/\Ashow\b/)
|
|
32
|
+
id = args[/\Ashow\s+(\S+)\z/, 1]
|
|
33
|
+
id ? show_memory(id) : @ui.info("Usage: /memory show <id>")
|
|
34
|
+
elsif args.match?(/\Abackend\b/)
|
|
35
|
+
show_memory_backend(args[/\Abackend\s+(\S+)\z/, 1])
|
|
36
|
+
elsif args.match?(/\Aforget\b/)
|
|
37
|
+
id = args[/\Aforget\s+(\S+)\z/, 1]
|
|
38
|
+
id ? forget_memory(id) : @ui.info("Usage: /memory forget <id>")
|
|
39
|
+
elsif args.match?(/\Asearch\b/)
|
|
40
|
+
# `search` is a subcommand token, not a query term (#59): bare
|
|
41
|
+
# `/memory search` falls back to the summary instead of searching
|
|
42
|
+
# for the literal word "search".
|
|
43
|
+
query = args[/\Asearch\s+(.+)\z/, 1]
|
|
44
|
+
query ? search_memory(query) : show_memory_summary
|
|
45
|
+
else
|
|
46
|
+
search_memory(args)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# `/memory show <id>` (#184): a REAL id lookup (the store resolves the
|
|
53
|
+
# short-id prefix), not a substring search over content — an id used to
|
|
54
|
+
# match nothing. Rendering (incl. the temporal chain: Retired /
|
|
55
|
+
# Superseded by) is shared with the `rubino memory show` CLI verb.
|
|
56
|
+
def show_memory(id)
|
|
57
|
+
memory = memory_backend.find(id)
|
|
58
|
+
if memory.nil?
|
|
59
|
+
@ui.error("no fact with id #{id}.")
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
CLI::MemoryCommand.render(memory, ui: @ui)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# `/memory backend [name]` (#184): shows the active + available
|
|
67
|
+
# backends in-chat. SWITCHING stays CLI-only on purpose: every consumer
|
|
68
|
+
# (the lifecycle's retriever/flusher, the memory tool, this executor)
|
|
69
|
+
# memoizes its built backend, so an in-process flip would leave the live
|
|
70
|
+
# loop writing to the OLD store while /memory reads the new one — a
|
|
71
|
+
# half-applied switch. The CLI verb writes config and a restart applies
|
|
72
|
+
# it everywhere at once.
|
|
73
|
+
def show_memory_backend(name)
|
|
74
|
+
CLI::MemoryCommand.render_active_backend(ui: @ui)
|
|
75
|
+
return unless name
|
|
76
|
+
|
|
77
|
+
@ui.info("Switching is CLI-only: run `rubino memory backend #{name}` " \
|
|
78
|
+
"(a restart applies it to the whole agent).")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def show_memory_summary(include_retired: false)
|
|
82
|
+
store = memory_backend
|
|
83
|
+
backend = Rubino.configuration.dig("memory", "backend") || Rubino::Memory::Backends::DEFAULT_NAME
|
|
84
|
+
@ui.info("backend #{backend} · #{store.count} facts")
|
|
85
|
+
|
|
86
|
+
memories = store.list(limit: 10, include_retired: include_retired)
|
|
87
|
+
if memories.empty?
|
|
88
|
+
@ui.info("No facts stored yet — the agent records them as it learns about you.")
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
render_memory_table(memories)
|
|
93
|
+
@ui.info("/memory <query> · /memory show <id> · /memory forget <id>")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def search_memory(query)
|
|
97
|
+
needle = query.downcase
|
|
98
|
+
matches = memory_backend.list(limit: 200)
|
|
99
|
+
.select { |m| m[:content].to_s.downcase.include?(needle) }
|
|
100
|
+
if matches.empty?
|
|
101
|
+
@ui.info("No facts matching #{query.inspect}.")
|
|
102
|
+
return
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
shown = matches.first(20)
|
|
106
|
+
@ui.info(%(#{shown.length} match#{"es" if shown.length != 1} for #{query.inspect}))
|
|
107
|
+
# A targeted search must SHOW the matched fact in full — the list-view's
|
|
108
|
+
# narrow truncation hides exactly the part the user searched for (#85).
|
|
109
|
+
# Print each match's full content, wrapping to the terminal width.
|
|
110
|
+
shown.each { |m| render_memory_match(m) }
|
|
111
|
+
@ui.info("/memory forget <id> to delete one")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# One searched fact, content shown end-to-end (wrapped, never truncated).
|
|
115
|
+
def render_memory_match(memory)
|
|
116
|
+
head = "#{memory[:id].to_s[0..7]} #{memory[:kind]} "
|
|
117
|
+
content = memory[:content].to_s.gsub(/\s+/, " ").strip
|
|
118
|
+
wrap_skill_line(head, content).each { |line| @ui.info(line) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def forget_memory(id)
|
|
122
|
+
store = memory_backend
|
|
123
|
+
memory = store.find(id)
|
|
124
|
+
if memory.nil?
|
|
125
|
+
@ui.error("no fact with id #{id}.")
|
|
126
|
+
return
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Destructive, irreversible delete — confirm first, default No (#218).
|
|
130
|
+
# A piped/Esc/EOF decline must NOT forget the fact.
|
|
131
|
+
confirmed = @ui.confirm_destructive(
|
|
132
|
+
%(Forget fact #{memory[:id][0..7]} "#{truncate(memory[:content], 60)}"? This cannot be undone.)
|
|
133
|
+
)
|
|
134
|
+
unless confirmed
|
|
135
|
+
@ui.info("Aborted.")
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
store.delete(memory[:id])
|
|
140
|
+
@ui.success(%(Forgot #{memory[:id][0..7]} "#{truncate(memory[:content], 60)}"))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Resolve the *configured* memory backend (default: sqlite tiny-Zep), the
|
|
144
|
+
# same store the agent loop, the `rubino memory` CLI and the HTTP
|
|
145
|
+
# `/v1/memory` ops use. The old `Memory::Store.new` was hardwired to the
|
|
146
|
+
# legacy `:memories` table and ignored `memory.backend`, so in-chat
|
|
147
|
+
# `/memory` never saw the facts the agent actually persists (#106).
|
|
148
|
+
def memory_backend
|
|
149
|
+
@memory_backend ||= Rubino::Memory::Backends.build
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The retired tombstone marker is shared with `rubino memory list --all`
|
|
153
|
+
# (CLI::MemoryCommand.retired_marker) so both surfaces speak one dialect.
|
|
154
|
+
def render_memory_table(memories)
|
|
155
|
+
rows = memories.map do |m|
|
|
156
|
+
[m[:id].to_s[0..7], m[:kind].to_s,
|
|
157
|
+
"#{truncate(m[:content], 60)}#{CLI::MemoryCommand.retired_marker(m)}"]
|
|
158
|
+
end
|
|
159
|
+
@ui.table(headers: %w[ID Kind Content], rows: rows)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Wraps "<head><description>" to the terminal width, breaking only on
|
|
163
|
+
# whitespace, with continuation lines indented to the description column.
|
|
164
|
+
def wrap_skill_line(head, description)
|
|
165
|
+
width = terminal_width
|
|
166
|
+
indent = " " * head.length
|
|
167
|
+
avail = [width - head.length, 20].max
|
|
168
|
+
|
|
169
|
+
lines = []
|
|
170
|
+
current = +""
|
|
171
|
+
description.split(/\s+/).each do |word|
|
|
172
|
+
candidate = current.empty? ? word : "#{current} #{word}"
|
|
173
|
+
if candidate.length > avail && !current.empty?
|
|
174
|
+
lines << current
|
|
175
|
+
current = word.dup
|
|
176
|
+
else
|
|
177
|
+
current = candidate
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
lines << current unless current.empty?
|
|
181
|
+
lines = [""] if lines.empty?
|
|
182
|
+
|
|
183
|
+
lines.each_with_index.map { |line, i| (i.zero? ? head : indent) + line }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def truncate(text, max)
|
|
187
|
+
s = text.to_s.gsub(/\s+/, " ").strip
|
|
188
|
+
s.length > max ? "#{s[0, max - 1]}…" : s
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def terminal_width
|
|
192
|
+
cols = IO.console&.winsize&.last
|
|
193
|
+
cols&.positive? ? cols : 80
|
|
194
|
+
rescue StandardError
|
|
195
|
+
80
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|