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,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module Session
|
|
8
|
+
# Thin CRUD wrapper over the `sessions` table. All session persistence
|
|
9
|
+
# goes through this class; callers should not touch the dataset directly.
|
|
10
|
+
#
|
|
11
|
+
# Notes:
|
|
12
|
+
# - #find supports prefix matching on the UUID so short ids from the CLI
|
|
13
|
+
# resolve to a full session row.
|
|
14
|
+
# - #latest_active is used to resume the most recently touched session.
|
|
15
|
+
# - #destroy! cascades manually to events, tool_calls, messages,
|
|
16
|
+
# session_summaries and runs inside a single transaction (no FK cascade
|
|
17
|
+
# in schema; the runs FK would otherwise block the session delete).
|
|
18
|
+
class Repository
|
|
19
|
+
def initialize(db: nil)
|
|
20
|
+
@db = db || Rubino.database.db
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Creates a new session and returns its record
|
|
24
|
+
def create(source:, model: nil, provider: nil, title: nil, parent_session_id: nil)
|
|
25
|
+
now = Time.now.utc.iso8601
|
|
26
|
+
id = generate_id
|
|
27
|
+
|
|
28
|
+
@db[:sessions].insert(
|
|
29
|
+
id: id,
|
|
30
|
+
parent_session_id: parent_session_id,
|
|
31
|
+
source: source,
|
|
32
|
+
model: model,
|
|
33
|
+
provider: provider,
|
|
34
|
+
title: title,
|
|
35
|
+
status: "active",
|
|
36
|
+
owner_pid: Process.pid,
|
|
37
|
+
message_count: 0,
|
|
38
|
+
token_count: 0,
|
|
39
|
+
created_at: now,
|
|
40
|
+
updated_at: now
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
find(id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Builds an UNSAVED session record (in-memory only) with a real id, so the
|
|
47
|
+
# CLI can open `chat` without persisting a row until the user actually
|
|
48
|
+
# sends a message (#144). The row is inserted lazily by #persist! on the
|
|
49
|
+
# first message; a session the user opens and immediately exits never
|
|
50
|
+
# touches the DB, so `/sessions` stays free of (untitled)/0-msg junk.
|
|
51
|
+
def build(source:, model: nil, provider: nil, title: nil, parent_session_id: nil)
|
|
52
|
+
now = Time.now.utc.iso8601
|
|
53
|
+
{
|
|
54
|
+
id: generate_id,
|
|
55
|
+
parent_session_id: parent_session_id,
|
|
56
|
+
source: source,
|
|
57
|
+
model: model,
|
|
58
|
+
provider: provider,
|
|
59
|
+
title: title,
|
|
60
|
+
status: "active",
|
|
61
|
+
message_count: 0,
|
|
62
|
+
token_count: 0,
|
|
63
|
+
created_at: now,
|
|
64
|
+
updated_at: now,
|
|
65
|
+
persisted: false
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Inserts a session row built by #build if it isn't already in the DB.
|
|
70
|
+
# Idempotent: a no-op once persisted (the common per-message path checks
|
|
71
|
+
# this first). Returns the (now persisted) session record.
|
|
72
|
+
def persist!(session)
|
|
73
|
+
return session if session[:persisted] || persisted?(session[:id])
|
|
74
|
+
|
|
75
|
+
@db[:sessions].insert(
|
|
76
|
+
id: session[:id],
|
|
77
|
+
parent_session_id: session[:parent_session_id],
|
|
78
|
+
source: session[:source],
|
|
79
|
+
model: session[:model],
|
|
80
|
+
provider: session[:provider],
|
|
81
|
+
title: session[:title],
|
|
82
|
+
status: session[:status] || "active",
|
|
83
|
+
owner_pid: Process.pid,
|
|
84
|
+
message_count: 0,
|
|
85
|
+
token_count: 0,
|
|
86
|
+
created_at: session[:created_at] || Time.now.utc.iso8601,
|
|
87
|
+
updated_at: Time.now.utc.iso8601
|
|
88
|
+
)
|
|
89
|
+
session[:persisted] = true
|
|
90
|
+
session
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# True when a row with this id exists in the sessions table.
|
|
94
|
+
def persisted?(id)
|
|
95
|
+
return false if id.nil?
|
|
96
|
+
|
|
97
|
+
!@db[:sessions].where(id: id).empty?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Finds a session by ID (supports prefix matching)
|
|
101
|
+
def find(id)
|
|
102
|
+
@db[:sessions].where(Sequel.like(:id, "#{id}%")).first
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Resolves a user-supplied query to a session: tries ID prefix first
|
|
106
|
+
# (handles "abc12345" style short IDs), then falls back to a case-
|
|
107
|
+
# insensitive substring match across the 50 most recent sessions —
|
|
108
|
+
# against the title AND the full first user message. The stored title
|
|
109
|
+
# is truncated (~60 chars), so a memorable word from the TAIL of a long
|
|
110
|
+
# first prompt would otherwise silently fail to resume (#70).
|
|
111
|
+
# Returns the session row or nil. Centralised so the CLI Runner and
|
|
112
|
+
# the TUI history loader agree on what `--resume <query>` accepts.
|
|
113
|
+
#
|
|
114
|
+
# Raises AmbiguousSessionError when >1 session matches, so the CLI
|
|
115
|
+
# can show the candidates instead of silently picking the first row
|
|
116
|
+
# — see issue triaged from the audit (#116).
|
|
117
|
+
def find_by_id_or_title(query)
|
|
118
|
+
return nil if query.nil? || query.to_s.empty?
|
|
119
|
+
|
|
120
|
+
id_matches = @db[:sessions].where(Sequel.like(:id, "#{query}%")).all
|
|
121
|
+
if id_matches.size > 1
|
|
122
|
+
raise AmbiguousSessionError.new(query, id_matches)
|
|
123
|
+
elsif id_matches.size == 1
|
|
124
|
+
return id_matches.first
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
needle = query.to_s.downcase
|
|
128
|
+
title_matches = list(limit: 50).select do |s|
|
|
129
|
+
s[:title]&.downcase&.include?(needle) ||
|
|
130
|
+
first_user_message(s[:id])&.downcase&.include?(needle)
|
|
131
|
+
end
|
|
132
|
+
if title_matches.size > 1
|
|
133
|
+
raise AmbiguousSessionError.new(query, title_matches)
|
|
134
|
+
elsif title_matches.size == 1
|
|
135
|
+
return title_matches.first
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Lists sessions with optional filters
|
|
142
|
+
def list(limit: 20, status: nil, search: nil)
|
|
143
|
+
dataset = @db[:sessions].order(Sequel.desc(:created_at), Sequel.desc(Sequel.lit("rowid"))).limit(limit)
|
|
144
|
+
dataset = dataset.where(status: status) if status
|
|
145
|
+
dataset = dataset.where(Sequel.like(:title, "%#{search}%")) if search && !search.empty?
|
|
146
|
+
dataset.all
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Updates a session's attributes
|
|
150
|
+
def update(id, **attrs)
|
|
151
|
+
attrs[:updated_at] = Time.now.utc.iso8601
|
|
152
|
+
@db[:sessions].where(id: id).update(attrs)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Increments message count
|
|
156
|
+
def increment_message_count!(id)
|
|
157
|
+
@db[:sessions].where(id: id).update(
|
|
158
|
+
message_count: Sequel[:message_count] + 1,
|
|
159
|
+
updated_at: Time.now.utc.iso8601
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Updates token count
|
|
164
|
+
def update_token_count!(id, token_count)
|
|
165
|
+
@db[:sessions].where(id: id).update(
|
|
166
|
+
token_count: token_count,
|
|
167
|
+
updated_at: Time.now.utc.iso8601
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Ends a session
|
|
172
|
+
def end_session!(id)
|
|
173
|
+
now = Time.now.utc.iso8601
|
|
174
|
+
@db[:sessions].where(id: id).update(
|
|
175
|
+
status: "ended",
|
|
176
|
+
ended_at: now,
|
|
177
|
+
owner_pid: nil,
|
|
178
|
+
updated_at: now
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Reaps orphaned sessions: any row still "active" whose owning process is
|
|
183
|
+
# gone is stamped "ended" (#11). This covers the un-trappable hard kill
|
|
184
|
+
# (SIGKILL) and a closed terminal whose SIGHUP never reached the process,
|
|
185
|
+
# where neither the clean-exit path nor the signal traps ran. Rows owned
|
|
186
|
+
# by a live process (including the current one) and rows with no recorded
|
|
187
|
+
# pid (pre-#11 / future sources) are left untouched. Called lazily before
|
|
188
|
+
# listing/resuming sessions; best-effort, returns the number reaped.
|
|
189
|
+
def reap_orphaned_active!
|
|
190
|
+
reaped = 0
|
|
191
|
+
@db[:sessions]
|
|
192
|
+
.where(status: "active")
|
|
193
|
+
.exclude(owner_pid: nil)
|
|
194
|
+
.select(:id, :owner_pid)
|
|
195
|
+
.each do |row|
|
|
196
|
+
next if process_alive?(row[:owner_pid])
|
|
197
|
+
|
|
198
|
+
end_session!(row[:id])
|
|
199
|
+
reaped += 1
|
|
200
|
+
end
|
|
201
|
+
reaped
|
|
202
|
+
rescue StandardError
|
|
203
|
+
reaped
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Returns the most recent active session, if any
|
|
207
|
+
def latest_active
|
|
208
|
+
@db[:sessions]
|
|
209
|
+
.where(status: "active")
|
|
210
|
+
.order(Sequel.desc(:updated_at), Sequel.desc(Sequel.lit("rowid")))
|
|
211
|
+
.first
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Returns the most recent session worth resuming on a bare `chat`: the
|
|
215
|
+
# last session that actually has messages, regardless of status, so a
|
|
216
|
+
# closed terminal (status still "active") OR a cleanly ended session can
|
|
217
|
+
# both be continued. Empty 0-message sessions are skipped so a stray
|
|
218
|
+
# earlier launch never shadows the real conversation (#99). Returns nil on
|
|
219
|
+
# a true first run, which the CLI uses to fall back to the welcome panel.
|
|
220
|
+
def latest_resumable
|
|
221
|
+
@db[:sessions]
|
|
222
|
+
.where { message_count > 0 }
|
|
223
|
+
.order(Sequel.desc(:updated_at), Sequel.desc(Sequel.lit("rowid")))
|
|
224
|
+
.first
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# A first prompt shorter than this is junk for titling purposes (#128): a
|
|
228
|
+
# throwaway "y"/"ok" the user immediately interrupted would otherwise
|
|
229
|
+
# become the session title and a useless one-char `--resume "y"` matcher.
|
|
230
|
+
TITLE_MIN_CHARS = 3
|
|
231
|
+
|
|
232
|
+
# Derives a short, human-readable session title from the first user
|
|
233
|
+
# message. Deterministic and model-free (#103): collapse whitespace, strip
|
|
234
|
+
# a leading slash-command word, take the first line, and truncate on a word
|
|
235
|
+
# boundary. Returns nil for empty/blank input — and for junk-short input
|
|
236
|
+
# (#128) — so the caller leaves the session untitled; the next MEANINGFUL
|
|
237
|
+
# prompt titles it instead (Lifecycle#maybe_set_title retries every turn
|
|
238
|
+
# until a title sticks), and the resume hint falls back to the session id.
|
|
239
|
+
def self.derive_title(text, max: 60)
|
|
240
|
+
cleaned = text.to_s.split("\n").first.to_s.strip.gsub(/\s+/, " ")
|
|
241
|
+
cleaned = cleaned.sub(%r{\A/\S+\s*}, "") # drop a leading slash command
|
|
242
|
+
return nil if cleaned.length < TITLE_MIN_CHARS
|
|
243
|
+
return cleaned if cleaned.length <= max
|
|
244
|
+
|
|
245
|
+
truncated = cleaned[0, max].sub(/\s+\S*\z/, "")
|
|
246
|
+
truncated = cleaned[0, max] if truncated.empty?
|
|
247
|
+
"#{truncated}…"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Deletes a session and all related records
|
|
251
|
+
def destroy!(id)
|
|
252
|
+
@db.transaction do
|
|
253
|
+
@db[:events].where(session_id: id).delete
|
|
254
|
+
@db[:tool_calls].where(session_id: id).delete
|
|
255
|
+
@db[:messages].where(session_id: id).delete
|
|
256
|
+
@db[:session_summaries].where(session_id: id).delete
|
|
257
|
+
@db[:runs].where(session_id: id).delete
|
|
258
|
+
@db[:sessions].where(id: id).delete
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
private
|
|
263
|
+
|
|
264
|
+
# The full first user message of a session — what derive_title truncated
|
|
265
|
+
# the title from — so resume-by-title can match the whole prompt (#70).
|
|
266
|
+
def first_user_message(session_id)
|
|
267
|
+
@db[:messages]
|
|
268
|
+
.where(session_id: session_id, role: "user")
|
|
269
|
+
.order(:created_at, Sequel.lit("rowid"))
|
|
270
|
+
.get(:content)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# True when a process with this pid is currently alive and signalable by
|
|
274
|
+
# us. Process.kill(0, pid) is the canonical liveness probe: it sends no
|
|
275
|
+
# signal but raises Errno::ESRCH when the pid is gone. Errno::EPERM means
|
|
276
|
+
# the pid exists but is owned by another user — still alive, do not reap.
|
|
277
|
+
def process_alive?(pid)
|
|
278
|
+
return false if pid.nil?
|
|
279
|
+
|
|
280
|
+
Process.kill(0, pid)
|
|
281
|
+
true
|
|
282
|
+
rescue Errno::ESRCH
|
|
283
|
+
false
|
|
284
|
+
rescue Errno::EPERM
|
|
285
|
+
true
|
|
286
|
+
rescue StandardError
|
|
287
|
+
true # unknown error: be conservative and keep the session
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def generate_id
|
|
291
|
+
SecureRandom.uuid
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Session
|
|
7
|
+
# Persists and queries messages within a session.
|
|
8
|
+
#
|
|
9
|
+
# Ordering note: created_at is iso8601 with second precision, so multiple
|
|
10
|
+
# messages can share the same timestamp. Read/delete paths that need a
|
|
11
|
+
# strict total order break ties on the SQLite `rowid` column.
|
|
12
|
+
#
|
|
13
|
+
# #last_for_role is the entry point used by retry/undo to find the last
|
|
14
|
+
# user (or assistant) turn before rewinding history.
|
|
15
|
+
class Store
|
|
16
|
+
def initialize(db: nil)
|
|
17
|
+
@db = db || Rubino.database.db
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Appends a message to a session
|
|
21
|
+
def append(message)
|
|
22
|
+
raise SessionError, "Invalid message" unless message.valid?
|
|
23
|
+
|
|
24
|
+
@db[:messages].insert(message.to_row)
|
|
25
|
+
message
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Creates and appends a message from attributes
|
|
29
|
+
def create(session_id:, role:, content:, **attrs)
|
|
30
|
+
message = Message.new(
|
|
31
|
+
session_id: session_id,
|
|
32
|
+
role: role,
|
|
33
|
+
content: content,
|
|
34
|
+
**attrs
|
|
35
|
+
)
|
|
36
|
+
append(message)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Copies messages into another session preserving ALL wire-significant
|
|
40
|
+
# fields. Assistant tool calls live in metadata[:tool_calls] (not
|
|
41
|
+
# tool_call_id), so dropping metadata orphans the toolUse block and 400s
|
|
42
|
+
# strict providers (Anthropic/Bedrock) on resume. token_count is copied
|
|
43
|
+
# too so the target session's budget accounting stays accurate.
|
|
44
|
+
def copy_into(target_session_id, messages)
|
|
45
|
+
messages.each do |msg|
|
|
46
|
+
create(
|
|
47
|
+
session_id: target_session_id,
|
|
48
|
+
role: msg.role,
|
|
49
|
+
content: msg.content,
|
|
50
|
+
tool_name: msg.tool_name,
|
|
51
|
+
tool_call_id: msg.tool_call_id,
|
|
52
|
+
token_count: msg.token_count,
|
|
53
|
+
metadata: msg.metadata
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Returns all messages for a session in chronological order.
|
|
59
|
+
# created_at is second-precision, so we tie-break on rowid — without
|
|
60
|
+
# this, an assistant preamble and a tool_result persisted in the same
|
|
61
|
+
# second can come back swapped, which makes the resumed transcript
|
|
62
|
+
# look like the tool fired before the model's preamble (or worse, like
|
|
63
|
+
# an empty assistant box wrapping the tool).
|
|
64
|
+
def for_session(session_id, limit: nil)
|
|
65
|
+
dataset = @db[:messages]
|
|
66
|
+
.where(session_id: session_id)
|
|
67
|
+
.order(:created_at, Sequel.lit("rowid"))
|
|
68
|
+
dataset = dataset.limit(limit) if limit
|
|
69
|
+
dataset.all.map { |row| hydrate(row) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Returns the N most recent messages for a session
|
|
73
|
+
def recent(session_id, count:)
|
|
74
|
+
@db[:messages]
|
|
75
|
+
.where(session_id: session_id)
|
|
76
|
+
.order(Sequel.desc(:created_at), Sequel.desc(Sequel.lit("rowid")))
|
|
77
|
+
.limit(count)
|
|
78
|
+
.all
|
|
79
|
+
.reverse
|
|
80
|
+
.map { |row| hydrate(row) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Returns total message count for a session
|
|
84
|
+
def count(session_id)
|
|
85
|
+
@db[:messages].where(session_id: session_id).count
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Returns estimated token sum for a session
|
|
89
|
+
def token_sum(session_id)
|
|
90
|
+
@db[:messages]
|
|
91
|
+
.where(session_id: session_id)
|
|
92
|
+
.sum(:token_count) || 0
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Deletes the given message and every message inserted after it.
|
|
96
|
+
# Used by undo/retry to rewind history.
|
|
97
|
+
#
|
|
98
|
+
# Uses tuple ordering on (created_at, rowid): rows strictly later by
|
|
99
|
+
# timestamp are removed, and ties on created_at are broken by rowid so
|
|
100
|
+
# same-second inserts are still cut at the right point.
|
|
101
|
+
#
|
|
102
|
+
# @param session_id [String]
|
|
103
|
+
# @param from_id [String] id of the first message to delete
|
|
104
|
+
# @return [Integer] number of rows removed
|
|
105
|
+
def delete_from_inclusive(session_id, from_id:)
|
|
106
|
+
msg = @db[:messages]
|
|
107
|
+
.where(id: from_id, session_id: session_id)
|
|
108
|
+
.select(:created_at, Sequel.lit("rowid AS row_id"))
|
|
109
|
+
.first
|
|
110
|
+
return 0 unless msg
|
|
111
|
+
|
|
112
|
+
@db[:messages]
|
|
113
|
+
.where(session_id: session_id)
|
|
114
|
+
.where(Sequel.lit("(created_at > ?) OR (created_at = ? AND rowid >= ?)",
|
|
115
|
+
msg[:created_at], msg[:created_at], msg[:row_id]))
|
|
116
|
+
.delete
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Full-text search across messages backed by the `messages_fts` FTS5
|
|
120
|
+
# virtual table (see migration 007). Returns hydrated rows with an
|
|
121
|
+
# FTS5 snippet() highlighting the match. Filters compose on top of the
|
|
122
|
+
# FTS MATCH so the index does the heavy lifting and SQL prunes the rest.
|
|
123
|
+
#
|
|
124
|
+
# @param query [String] FTS5 MATCH expression; sanitized via Quoting
|
|
125
|
+
# @param since [String, nil] iso8601 lower bound on created_at
|
|
126
|
+
# @param until_ [String, nil] iso8601 upper bound on created_at
|
|
127
|
+
# @param role [String, nil] restrict to a specific message role
|
|
128
|
+
# @param tool [String, nil] restrict to a specific tool_name
|
|
129
|
+
# @param limit [Integer] cap on rows returned (max 100)
|
|
130
|
+
# @return [Array<Hash>] rows: session_id, run_id (nil — not tracked on
|
|
131
|
+
# messages), message_id, role, snippet, created_at
|
|
132
|
+
def search(query:, since: nil, until_: nil, role: nil, tool: nil, limit: 20)
|
|
133
|
+
return [] if query.nil? || query.to_s.strip.empty?
|
|
134
|
+
|
|
135
|
+
limit = limit.to_i.clamp(1, 100)
|
|
136
|
+
match_query = sanitize_fts_query(query)
|
|
137
|
+
|
|
138
|
+
dataset = @db[:messages_fts]
|
|
139
|
+
.where(Sequel.lit("messages_fts MATCH ?", match_query))
|
|
140
|
+
.join(:messages, Sequel[:messages][:rowid] => Sequel[:messages_fts][:rowid])
|
|
141
|
+
.select(
|
|
142
|
+
Sequel[:messages][:id].as(:message_id),
|
|
143
|
+
Sequel[:messages][:session_id],
|
|
144
|
+
Sequel[:messages][:role],
|
|
145
|
+
Sequel[:messages][:created_at],
|
|
146
|
+
Sequel.lit("snippet(messages_fts, 0, '<mark>', '</mark>', '...', 16) AS snippet")
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
dataset = dataset.where(Sequel[:messages][:role] => role) if role
|
|
150
|
+
dataset = dataset.where(Sequel[:messages][:tool_name] => tool) if tool
|
|
151
|
+
dataset = dataset.where(Sequel.lit("messages.created_at >= ?", since)) if since
|
|
152
|
+
dataset = dataset.where(Sequel.lit("messages.created_at <= ?", until_)) if until_
|
|
153
|
+
|
|
154
|
+
dataset
|
|
155
|
+
.order(Sequel.desc(Sequel[:messages][:created_at]), Sequel.desc(Sequel.lit("messages.rowid")))
|
|
156
|
+
.limit(limit)
|
|
157
|
+
.all
|
|
158
|
+
.map { |row| row.merge(run_id: nil) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Returns the most recent message for `role` (e.g. "user", "assistant").
|
|
162
|
+
# Tie-broken on rowid like the other read paths. Used by retry/undo.
|
|
163
|
+
def last_for_role(session_id, role)
|
|
164
|
+
row = @db[:messages]
|
|
165
|
+
.where(session_id: session_id, role: role)
|
|
166
|
+
.order(Sequel.desc(:created_at), Sequel.desc(Sequel.lit("rowid")))
|
|
167
|
+
.first
|
|
168
|
+
row && hydrate(row)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
# FTS5 MATCH treats unquoted strings as expression syntax — a stray
|
|
174
|
+
# double quote or a token starting with `-`/`*` raises a syntax error
|
|
175
|
+
# at query time. Wrap the whole query as a single quoted phrase
|
|
176
|
+
# (doubling any embedded quotes) so user input is always literal.
|
|
177
|
+
def sanitize_fts_query(query)
|
|
178
|
+
"\"#{query.to_s.gsub('"', '""')}\""
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def hydrate(row)
|
|
182
|
+
metadata = row[:metadata_json] ? JSON.parse(row[:metadata_json], symbolize_names: true) : {}
|
|
183
|
+
|
|
184
|
+
Message.new(
|
|
185
|
+
id: row[:id],
|
|
186
|
+
session_id: row[:session_id],
|
|
187
|
+
role: row[:role],
|
|
188
|
+
content: row[:content],
|
|
189
|
+
tool_name: row[:tool_name],
|
|
190
|
+
tool_call_id: row[:tool_call_id],
|
|
191
|
+
token_count: row[:token_count],
|
|
192
|
+
metadata: metadata,
|
|
193
|
+
created_at: row[:created_at]
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Rubino
|
|
6
|
+
module Session
|
|
7
|
+
# Single owner of the `session_summaries` table.
|
|
8
|
+
#
|
|
9
|
+
# Compaction summaries used to be read and written from three places
|
|
10
|
+
# (Context::Compressor, Context::SummaryBuilder, Context::PromptAssembler)
|
|
11
|
+
# with near-identical Sequel blocks that DIVERGED: the compressor stamped
|
|
12
|
+
# parent_summary_id to chain lineage, the builder's own save! did not — so
|
|
13
|
+
# whether a summary linked to its parent depended on which code path
|
|
14
|
+
# happened to write it. Centralising here means the row shape and the
|
|
15
|
+
# parent lineage live in exactly one place.
|
|
16
|
+
#
|
|
17
|
+
# "latest" is defined as the most recent created_at for a session
|
|
18
|
+
# (iso8601, ordered desc) — the same ordering every former caller used.
|
|
19
|
+
class SummaryStore
|
|
20
|
+
def initialize(db: nil)
|
|
21
|
+
@db = db || Rubino.database.db
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Most recent summary record for a session (or nil).
|
|
25
|
+
def latest(session_id)
|
|
26
|
+
dataset(session_id).first
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Content of the most recent summary (or nil) — the read path used when
|
|
30
|
+
# only the text is needed (prompt assembly, previous-summary carry-over).
|
|
31
|
+
def latest_content(session_id)
|
|
32
|
+
latest(session_id)&.dig(:content)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Id of the most recent summary (or nil) — used as the parent link when
|
|
36
|
+
# recording compaction lineage.
|
|
37
|
+
def latest_id(session_id)
|
|
38
|
+
latest(session_id)&.dig(:id)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Inserts a new summary, chaining parent_summary_id to the current latest
|
|
42
|
+
# so lineage is always recorded regardless of caller. Returns the new id.
|
|
43
|
+
def insert(session_id:, content:)
|
|
44
|
+
id = SecureRandom.uuid
|
|
45
|
+
@db[:session_summaries].insert(
|
|
46
|
+
id: id,
|
|
47
|
+
session_id: session_id,
|
|
48
|
+
parent_summary_id: latest_id(session_id),
|
|
49
|
+
content: content,
|
|
50
|
+
token_count: (content.length / 4.0).ceil,
|
|
51
|
+
created_at: Time.now.utc.iso8601
|
|
52
|
+
)
|
|
53
|
+
id
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def dataset(session_id)
|
|
59
|
+
@db[:session_summaries]
|
|
60
|
+
.where(session_id: session_id)
|
|
61
|
+
.order(Sequel.desc(:created_at))
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Skills
|
|
5
|
+
# Builds the "## Skills (mandatory)" block injected into the SYSTEM PROMPT.
|
|
6
|
+
#
|
|
7
|
+
# This is the load-bearing trigger for skill auto-activation: surfacing the
|
|
8
|
+
# skill catalogue inside the system prompt (not just the `skill` tool's
|
|
9
|
+
# description) is what makes the model proactively scan and load a relevant
|
|
10
|
+
# skill before replying. Mirrors the reference build_skills_system_prompt,
|
|
11
|
+
# adapted to rubino's `skill(name)`
|
|
12
|
+
# invocation and flat name+description catalogue.
|
|
13
|
+
#
|
|
14
|
+
# Always renders a block when the skills feature is on (the caller gates on
|
|
15
|
+
# that): the catalogue half is dropped when no skills exist, but the
|
|
16
|
+
# CREATION half is always present so even a fresh install with zero skills
|
|
17
|
+
# nudges the agent to distill repeatable work into a new skill. Never
|
|
18
|
+
# returns nil — an empty registry is a valid state that still wants the
|
|
19
|
+
# create nudge.
|
|
20
|
+
class PromptIndex
|
|
21
|
+
# Where a freshly authored skill should be written. Mirrors the Registry's
|
|
22
|
+
# project-local default path; surfaced in the create nudge so the agent
|
|
23
|
+
# knows the exact destination + filename contract.
|
|
24
|
+
DEFAULT_SKILL_DIR = ".rubino/skills"
|
|
25
|
+
|
|
26
|
+
def initialize(registry: nil)
|
|
27
|
+
@registry = registry || Registry.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Renders the "## Skills (mandatory)" block: the available-skills
|
|
31
|
+
# catalogue (when any exist) followed by the proactive-creation nudge
|
|
32
|
+
# (always). Never nil — see the class comment.
|
|
33
|
+
def render
|
|
34
|
+
[catalogue, creation_nudge].compact.join("\n\n")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# The load-bearing auto-LOAD trigger. Nil when no skills are discovered,
|
|
40
|
+
# so a fresh install shows only the create nudge instead of an empty
|
|
41
|
+
# <available_skills> block.
|
|
42
|
+
def catalogue
|
|
43
|
+
summaries = @registry.summaries
|
|
44
|
+
return nil if summaries.empty?
|
|
45
|
+
|
|
46
|
+
lines = summaries.map { |s| " - #{s}" }.join("\n")
|
|
47
|
+
<<~PROMPT.strip
|
|
48
|
+
## Skills (mandatory)
|
|
49
|
+
Before replying, scan the skills below. If a skill matches or is even partially relevant to your task, you MUST load it with skill(name) and follow its instructions. Err on the side of loading — it is always better to have context you don't need than to miss critical steps, pitfalls, or established workflows. Skills contain specialized knowledge — APIs, tool-specific commands, and proven workflows that outperform general-purpose approaches — and they encode the user's preferred conventions and quality standards. Load the relevant skill even for tasks you already know how to do, because the skill defines how it should be done here.
|
|
50
|
+
|
|
51
|
+
<available_skills>
|
|
52
|
+
#{lines}
|
|
53
|
+
</available_skills>
|
|
54
|
+
|
|
55
|
+
Only proceed without loading a skill if genuinely none are relevant to the task.
|
|
56
|
+
PROMPT
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The proactive-CREATION nudge — the counterpart to the load trigger.
|
|
60
|
+
# Without this the agent only ever consumes skills and never authors one,
|
|
61
|
+
# so a completed complex/repeatable task is lost instead of distilled into
|
|
62
|
+
# a reusable skill (skill-bench: proactive-creation F1 = 0). Gives the
|
|
63
|
+
# exact path + SKILL.md format so the agent can write the file with its
|
|
64
|
+
# normal file-writing tool, unprompted.
|
|
65
|
+
#
|
|
66
|
+
# Heads the block with the "## Skills" header when the catalogue is absent
|
|
67
|
+
# (fresh install) so the header is never orphaned.
|
|
68
|
+
def creation_nudge
|
|
69
|
+
header = @registry.summaries.empty? ? "## Skills\n" : ""
|
|
70
|
+
<<~PROMPT.strip
|
|
71
|
+
#{header}### Creating skills
|
|
72
|
+
When you finish a task that was complex, multi-step (typically 5+ tool calls), and likely to recur — and no existing skill already covers it — proactively capture it as a new skill so the next run is faster and more reliable. Do this at the natural end of the work, without being asked, and without interrupting the user mid-task. If the work was trivial, one-off, or already covered by a loaded skill, do NOT create one.
|
|
73
|
+
|
|
74
|
+
To create a skill, call the `skill` tool with action "create":
|
|
75
|
+
|
|
76
|
+
<skill_create>
|
|
77
|
+
skill(action: "create", name: "<kebab-case-name>", description: "One line saying what the skill is for and WHEN it applies — this is the only text future runs see before loading it, so make it match-on-sight.", body: "# <Title>\\n\\nThe proven, step-by-step instructions, commands, and pitfalls you just worked out. Be specific and prescriptive.")
|
|
78
|
+
</skill_create>
|
|
79
|
+
|
|
80
|
+
This writes `#{DEFAULT_SKILL_DIR}/<name>/SKILL.md` for you with valid frontmatter — you do not need the write/edit tool for this.
|
|
81
|
+
PROMPT
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|