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,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Memory
|
|
5
|
+
# Prevents duplicate memories from being stored.
|
|
6
|
+
# Uses content similarity to detect duplicates.
|
|
7
|
+
class Deduplicator
|
|
8
|
+
# Similarity threshold (0.0 to 1.0) - above this is considered duplicate
|
|
9
|
+
SIMILARITY_THRESHOLD = 0.85
|
|
10
|
+
|
|
11
|
+
def initialize(store: nil)
|
|
12
|
+
@store = store || Store.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Returns true if a similar memory already exists
|
|
16
|
+
def duplicate?(kind:, content:)
|
|
17
|
+
existing = @store.by_kind(kind, limit: 100)
|
|
18
|
+
existing.any? { |m| similar?(m[:content], content) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Removes duplicate memories, keeping the highest confidence version
|
|
22
|
+
def deduplicate_all!
|
|
23
|
+
removed = 0
|
|
24
|
+
Store::VALID_KINDS.each do |kind|
|
|
25
|
+
removed += deduplicate_kind(kind)
|
|
26
|
+
end
|
|
27
|
+
removed
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def similar?(text_a, text_b)
|
|
33
|
+
return true if text_a == text_b
|
|
34
|
+
|
|
35
|
+
# Simple Jaccard similarity on word sets
|
|
36
|
+
words_a = text_a.downcase.split(/\W+/).to_set
|
|
37
|
+
words_b = text_b.downcase.split(/\W+/).to_set
|
|
38
|
+
|
|
39
|
+
return false if words_a.empty? || words_b.empty?
|
|
40
|
+
|
|
41
|
+
intersection = (words_a & words_b).size
|
|
42
|
+
union = (words_a | words_b).size
|
|
43
|
+
|
|
44
|
+
(intersection.to_f / union) >= SIMILARITY_THRESHOLD
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def deduplicate_kind(kind)
|
|
48
|
+
memories = @store.by_kind(kind, limit: 500)
|
|
49
|
+
to_remove = []
|
|
50
|
+
|
|
51
|
+
memories.each_with_index do |mem, i|
|
|
52
|
+
next if to_remove.include?(mem[:id])
|
|
53
|
+
|
|
54
|
+
memories[(i + 1)..].each do |other|
|
|
55
|
+
next if to_remove.include?(other[:id])
|
|
56
|
+
|
|
57
|
+
if similar?(mem[:content], other[:content])
|
|
58
|
+
# Keep the one with higher confidence
|
|
59
|
+
if mem[:confidence] >= (other[:confidence] || 0)
|
|
60
|
+
to_remove << other[:id]
|
|
61
|
+
else
|
|
62
|
+
to_remove << mem[:id]
|
|
63
|
+
break
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
to_remove.each { |id| @store.delete(id) }
|
|
70
|
+
to_remove.size
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Memory
|
|
5
|
+
# Extracts potential memories from conversation history.
|
|
6
|
+
# Identifies facts, preferences, decisions, and other memorable items.
|
|
7
|
+
class Extractor
|
|
8
|
+
# Patterns that suggest extractable memories
|
|
9
|
+
PREFERENCE_PATTERNS = [
|
|
10
|
+
/(?:I prefer|I like|I always|I never|I usually|my preferred)/i,
|
|
11
|
+
/(?:please always|please never|don't ever|always use|never use)/i
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
DECISION_PATTERNS = [
|
|
15
|
+
/(?:we decided|the decision is|let's go with|I chose|we'll use)/i,
|
|
16
|
+
/(?:the approach is|the strategy is|we agreed on)/i
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(store: nil)
|
|
20
|
+
@store = store || Store.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Extracts memories from a session's messages
|
|
24
|
+
def extract_from_session(session_id)
|
|
25
|
+
message_store = Session::Store.new
|
|
26
|
+
messages = message_store.for_session(session_id)
|
|
27
|
+
extracted = []
|
|
28
|
+
|
|
29
|
+
messages.each do |msg|
|
|
30
|
+
next unless %w[user assistant].include?(msg.role)
|
|
31
|
+
next if msg.content.nil? || msg.content.empty?
|
|
32
|
+
|
|
33
|
+
memories = extract_from_content(msg.content, session_id)
|
|
34
|
+
extracted.concat(memories)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
extracted
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Extracts memories from a single content string
|
|
41
|
+
def extract_from_content(content, session_id = nil)
|
|
42
|
+
memories = []
|
|
43
|
+
|
|
44
|
+
# Check for preferences
|
|
45
|
+
if matches_patterns?(content, PREFERENCE_PATTERNS)
|
|
46
|
+
memories << save_memory(
|
|
47
|
+
kind: "preference",
|
|
48
|
+
content: content.strip[0..500],
|
|
49
|
+
session_id: session_id
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check for technical decisions
|
|
54
|
+
if matches_patterns?(content, DECISION_PATTERNS)
|
|
55
|
+
memories << save_memory(
|
|
56
|
+
kind: "technical_decision",
|
|
57
|
+
content: content.strip[0..500],
|
|
58
|
+
session_id: session_id
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
memories.compact
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def matches_patterns?(content, patterns)
|
|
68
|
+
patterns.any? { |p| content.match?(p) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def save_memory(kind:, content:, session_id:)
|
|
72
|
+
# Check for duplicates before saving
|
|
73
|
+
deduplicator = Deduplicator.new(store: @store)
|
|
74
|
+
return nil if deduplicator.duplicate?(kind: kind, content: content)
|
|
75
|
+
|
|
76
|
+
@store.create(
|
|
77
|
+
kind: kind,
|
|
78
|
+
content: content,
|
|
79
|
+
source_session_id: session_id,
|
|
80
|
+
confidence: 0.8
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Memory
|
|
5
|
+
# Flushes working memory to persistent storage before compaction.
|
|
6
|
+
# Ensures no important information is lost when context is compressed.
|
|
7
|
+
class Flusher
|
|
8
|
+
def initialize(backend: nil)
|
|
9
|
+
@backend = backend
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Flushes all pending memories for a session before compaction.
|
|
13
|
+
# Routes through the configured backend's extract path so compaction
|
|
14
|
+
# mines facts with the same backend the rest of the gem uses.
|
|
15
|
+
def flush_before_compaction!(session_id)
|
|
16
|
+
extracted = backend.extract(session_id)
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
flushed_count: extracted.size,
|
|
20
|
+
session_id: session_id
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def backend
|
|
27
|
+
@backend ||= Backends.build
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Memory
|
|
5
|
+
# Retrieves relevant memories for inclusion in prompts.
|
|
6
|
+
# Handles user profile, project context, and session-relevant memories.
|
|
7
|
+
class Retriever
|
|
8
|
+
def initialize(store: nil, config: nil)
|
|
9
|
+
@store = store || Store.new
|
|
10
|
+
@config = config || Rubino.configuration
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Returns the user profile text (concatenated user_profile memories)
|
|
14
|
+
def user_profile
|
|
15
|
+
return nil unless @config.dig("memory", "user_profile_enabled")
|
|
16
|
+
|
|
17
|
+
char_limit = @config.memory_user_char_limit
|
|
18
|
+
memories = @store.by_kind("user_profile")
|
|
19
|
+
|
|
20
|
+
text = memories.map { |m| m[:content] }.join("\n")
|
|
21
|
+
text.length > char_limit ? text[0...char_limit] : text
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns project context memories
|
|
25
|
+
def project_context
|
|
26
|
+
return nil unless @config.dig("memory", "project_context_enabled")
|
|
27
|
+
|
|
28
|
+
memories = @store.by_kind("project_context", limit: 10)
|
|
29
|
+
return nil if memories.empty?
|
|
30
|
+
|
|
31
|
+
memories.map { |m| m[:content] }.join("\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Returns memories relevant to the current session context
|
|
35
|
+
def relevant_for_session(_session_id)
|
|
36
|
+
char_limit = @config.memory_char_limit
|
|
37
|
+
@store.within_limit(char_limit: char_limit)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns all memories formatted for prompt inclusion
|
|
41
|
+
def for_prompt
|
|
42
|
+
{
|
|
43
|
+
user_profile: user_profile,
|
|
44
|
+
project_context: project_context,
|
|
45
|
+
general: @store.within_limit(char_limit: @config.memory_char_limit)
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubino
|
|
4
|
+
module Memory
|
|
5
|
+
# The single aux-LLM extraction prompt for the Sqlite backend. Collapses
|
|
6
|
+
# Zep's six-step ingestion (entity/fact/temporal extraction + invalidation)
|
|
7
|
+
# into ONE structured call: given the latest turn and the currently-live
|
|
8
|
+
# facts, the model returns durable atomic facts to `add` and contradicted
|
|
9
|
+
# facts to `supersede`. The doctrine ("durable declarative facts, not
|
|
10
|
+
# imperatives, not stale artifacts") is lifted from the reference MEMORY_GUIDANCE.
|
|
11
|
+
module SqliteExtractionPrompt
|
|
12
|
+
KINDS = %w[user_profile preference project fact env].freeze
|
|
13
|
+
|
|
14
|
+
SYSTEM = <<~PROMPT
|
|
15
|
+
You maintain a long-term memory of durable facts about the user and their project.
|
|
16
|
+
You will see the latest conversation turn and the facts already in memory.
|
|
17
|
+
|
|
18
|
+
Extract only DURABLE facts worth remembering across sessions:
|
|
19
|
+
- user identity, preferences, and recurring corrections (highest value — they reduce future steering)
|
|
20
|
+
- stable project/environment conventions and tool quirks
|
|
21
|
+
Write each as ONE atomic declarative fact in the third person, present tense.
|
|
22
|
+
GOOD: "User prefers concise answers without preamble."
|
|
23
|
+
GOOD: "Project uses pytest with the xdist plugin."
|
|
24
|
+
BAD (imperative): "Always answer concisely." BAD (procedure): "Run tests with pytest -n 4."
|
|
25
|
+
BAD (stale artifact): "Fixed bug #4821." "Opened PR 90." "Phase 2 done."
|
|
26
|
+
If a fact will be stale within a week (PR/issue/commit numbers, task progress, TODO state), DO NOT save it.
|
|
27
|
+
Procedures and how-to workflows are NOT memory — skip them.
|
|
28
|
+
|
|
29
|
+
SUPERSEDE: if a new fact CONTRADICTS an existing one (same subject, changed value),
|
|
30
|
+
emit it under "supersede" with the id of the fact it replaces. Prefer the newer information.
|
|
31
|
+
Tag each fact with 1-4 lowercase entity keywords (people, tools, projects) for retrieval.
|
|
32
|
+
|
|
33
|
+
EDGES (optional, light): if the turn states a clear RELATIONSHIP between two of the
|
|
34
|
+
entities you tagged, emit it under "edges" as {"src","relation","dst"} with a short
|
|
35
|
+
lowercase relation (e.g. uses, deploys_to, written_in, runs_on, depends_on). Keep it to
|
|
36
|
+
the few obvious relations the turn actually asserts — do not invent links. These let a
|
|
37
|
+
later query like "what does X use for Y" reach the connected fact. Omit when unsure.
|
|
38
|
+
|
|
39
|
+
Return STRICT JSON, no prose:
|
|
40
|
+
{ "add": [ {"text": "...", "kind": "preference|user_profile|project|fact|env",
|
|
41
|
+
"entities": ["..."], "valid_from": "<ISO8601 or null>"} ],
|
|
42
|
+
"supersede": [ {"id": "<existing fact id>", "by_text": "...", "kind": "...",
|
|
43
|
+
"entities": ["..."]} ],
|
|
44
|
+
"edges": [ {"src": "...", "relation": "...", "dst": "..."} ] }
|
|
45
|
+
If nothing is worth saving, return {"add": [], "supersede": [], "edges": []}.
|
|
46
|
+
PROMPT
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# Builds the USER message: reference timestamp, the live fact set (already
|
|
51
|
+
# char-capped by the caller), and the latest turn rendered as a transcript.
|
|
52
|
+
def user_message(now:, live_facts:, turn:)
|
|
53
|
+
live = if live_facts.empty?
|
|
54
|
+
"(none)"
|
|
55
|
+
else
|
|
56
|
+
live_facts.map { |f| "#{f[:id]} | #{f[:kind]} | #{f[:text]}" }.join("\n")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
<<~MSG
|
|
60
|
+
Reference timestamp: #{now}
|
|
61
|
+
Existing live facts:
|
|
62
|
+
#{live}
|
|
63
|
+
|
|
64
|
+
Latest turn:
|
|
65
|
+
#{turn}
|
|
66
|
+
MSG
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module Memory
|
|
8
|
+
# Graph-lite layer for the Sqlite backend (Memory Phase 3b).
|
|
9
|
+
#
|
|
10
|
+
# A thin mixin over two tables (memory_entities + memory_edges) that turns
|
|
11
|
+
# the per-fact entity tags into a tiny knowledge graph and blends a bounded
|
|
12
|
+
# 1-hop traversal into retrieval. NOT a graph DB — just entity resolution by
|
|
13
|
+
# normalized name and a bounded join over edges.
|
|
14
|
+
#
|
|
15
|
+
# Edges are populated two ways, both cheap (no extra LLM call beyond the
|
|
16
|
+
# single extraction call the backend already makes):
|
|
17
|
+
# * DETERMINISTIC co-occurrence — every pair of entities tagged on the
|
|
18
|
+
# same fact gets a `co_occurs` edge (free, derived from `entities_json`).
|
|
19
|
+
# * TYPED relations — the extraction LLM optionally returns `edges:
|
|
20
|
+
# [{src, relation, dst}]` in the SAME structured call, so the typed
|
|
21
|
+
# graph costs 0 additional calls/turn.
|
|
22
|
+
#
|
|
23
|
+
# Edges are bi-temporal like facts: a contradicting relation soft-retires
|
|
24
|
+
# the old edge (valid_to set), it is not deleted.
|
|
25
|
+
module SqliteGraph
|
|
26
|
+
ENTITIES = :memory_entities
|
|
27
|
+
EDGES = :memory_edges
|
|
28
|
+
CO_OCCURS = "co_occurs"
|
|
29
|
+
|
|
30
|
+
# ---- node resolution ----
|
|
31
|
+
|
|
32
|
+
# Resolve (find-or-create) an entity node by normalized name, returning
|
|
33
|
+
# its id. Same name from different facts collapses to one node.
|
|
34
|
+
def resolve_entity(name, kind: nil)
|
|
35
|
+
norm = normalize_entity_name(name)
|
|
36
|
+
return nil if norm.empty?
|
|
37
|
+
|
|
38
|
+
existing = @db[ENTITIES].where(name_norm: norm).first
|
|
39
|
+
return existing[:id] if existing
|
|
40
|
+
|
|
41
|
+
now = Time.now.utc.iso8601
|
|
42
|
+
id = SecureRandom.uuid
|
|
43
|
+
@db[ENTITIES].insert(
|
|
44
|
+
id: id, name: name.to_s.strip, name_norm: norm, kind: kind,
|
|
45
|
+
created_at: now, updated_at: now
|
|
46
|
+
)
|
|
47
|
+
id
|
|
48
|
+
rescue Sequel::UniqueConstraintViolation
|
|
49
|
+
# Concurrent insert: re-read the winner.
|
|
50
|
+
@db[ENTITIES].where(name_norm: norm).get(:id)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def normalize_entity_name(name)
|
|
54
|
+
name.to_s.strip.downcase.gsub(/\s+/, " ")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ---- edge population ----
|
|
58
|
+
|
|
59
|
+
# Wire the graph for a freshly-inserted fact: upsert its entity nodes,
|
|
60
|
+
# connect every co-occurring pair with a co_occurs edge, and add any
|
|
61
|
+
# typed relations the extractor emitted for this fact. Bounded and free
|
|
62
|
+
# of extra LLM calls. `typed` is an array of {src, relation, dst} hashes.
|
|
63
|
+
def index_fact_graph(fact_id, entities, typed: [])
|
|
64
|
+
ids = Array(entities).filter_map { |e| resolve_entity(e) }.uniq
|
|
65
|
+
ids.combination(2).each { |a, b| upsert_edge(a, b, CO_OCCURS, fact_id) }
|
|
66
|
+
|
|
67
|
+
Array(typed).each do |edge|
|
|
68
|
+
src = resolve_entity(edge["src"] || edge[:src])
|
|
69
|
+
dst = resolve_entity(edge["dst"] || edge[:dst])
|
|
70
|
+
rel = (edge["relation"] || edge[:relation]).to_s.strip.downcase
|
|
71
|
+
next if src.nil? || dst.nil? || src == dst || rel.empty?
|
|
72
|
+
|
|
73
|
+
# A changed typed relation between the SAME pair supersedes the old
|
|
74
|
+
# one (e.g. "uses postgres" -> "uses sqlite" is handled at the fact
|
|
75
|
+
# level; here we keep the latest relation label live).
|
|
76
|
+
supersede_edge(src, dst, rel)
|
|
77
|
+
upsert_edge(src, dst, rel, fact_id)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Insert a live edge unless an identical live edge already exists
|
|
82
|
+
# (idempotent). Co_occurs edges are undirected in effect: we store the
|
|
83
|
+
# canonical ordering for the pair so the de-dup works both ways.
|
|
84
|
+
def upsert_edge(src, dst, relation, source_fact_id)
|
|
85
|
+
a, b = relation == CO_OCCURS ? [src, dst].minmax : [src, dst]
|
|
86
|
+
return if @db[EDGES].where(
|
|
87
|
+
src_entity_id: a, dst_entity_id: b, relation: relation, valid_to: nil
|
|
88
|
+
).count.positive?
|
|
89
|
+
|
|
90
|
+
now = Time.now.utc.iso8601
|
|
91
|
+
@db[EDGES].insert(
|
|
92
|
+
id: SecureRandom.uuid, src_entity_id: a, dst_entity_id: b,
|
|
93
|
+
relation: relation, source_fact_id: source_fact_id,
|
|
94
|
+
valid_from: now, valid_to: nil, superseded_by: nil,
|
|
95
|
+
created_at: now, updated_at: now
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Soft-retire any live typed edge between src->dst whose relation differs,
|
|
100
|
+
# so a contradicting relation supersedes the old one (history kept).
|
|
101
|
+
def supersede_edge(src, dst, _relation)
|
|
102
|
+
@db[EDGES].where(src_entity_id: src, dst_entity_id: dst, valid_to: nil)
|
|
103
|
+
.exclude(relation: CO_OCCURS)
|
|
104
|
+
.update(valid_to: Time.now.utc.iso8601, updated_at: Time.now.utc.iso8601)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# ---- 1-hop traversal ----
|
|
108
|
+
|
|
109
|
+
# Given query text, find seed entities whose name appears in the query,
|
|
110
|
+
# walk LIVE edges out one hop to neighbor entities, and return the ids of
|
|
111
|
+
# LIVE facts tagged with any seed-or-neighbor entity. This surfaces facts
|
|
112
|
+
# connected through a relation that pure FTS on the probe would miss.
|
|
113
|
+
# Bounded: capped seeds, single hop, capped fact scan.
|
|
114
|
+
def graph_neighbors(query, limit)
|
|
115
|
+
seeds = seed_entities(query)
|
|
116
|
+
return [] if seeds.empty?
|
|
117
|
+
|
|
118
|
+
# 1-hop: neighbors reachable via a live edge in either direction.
|
|
119
|
+
neighbor_ids = @db[EDGES]
|
|
120
|
+
.where(valid_to: nil)
|
|
121
|
+
.where(Sequel.|({ src_entity_id: seeds }, { dst_entity_id: seeds }))
|
|
122
|
+
.select_map(%i[src_entity_id dst_entity_id])
|
|
123
|
+
.flatten.uniq
|
|
124
|
+
|
|
125
|
+
entity_ids = (seeds + neighbor_ids).uniq
|
|
126
|
+
return [] if entity_ids.empty?
|
|
127
|
+
|
|
128
|
+
names = @db[ENTITIES].where(id: entity_ids).select_map(:name_norm)
|
|
129
|
+
facts_tagged_with(names, limit)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Entities whose normalized name (or a token of it) appears in the query.
|
|
133
|
+
def seed_entities(query)
|
|
134
|
+
tokens = query.to_s.downcase.scan(/[\p{L}\p{N}]+/).reject { |w| w.length < 2 }.uniq
|
|
135
|
+
return [] if tokens.empty?
|
|
136
|
+
|
|
137
|
+
@db[ENTITIES].where(name_norm: tokens).select_map(:id).first(8)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Live fact ids whose entities_json contains any of the given normalized
|
|
141
|
+
# entity names. Bounded scan over the live set (small in practice).
|
|
142
|
+
def facts_tagged_with(norm_names, limit)
|
|
143
|
+
wanted = norm_names.to_set
|
|
144
|
+
return [] if wanted.empty?
|
|
145
|
+
|
|
146
|
+
live_dataset.exclude(entities_json: nil).order(Sequel.desc(:created_at))
|
|
147
|
+
.limit(limit * 6).all.filter_map do |row|
|
|
148
|
+
ents = parse_entities(row[:entities_json]).map { |e| e.to_s.downcase }
|
|
149
|
+
row[:id] if ents.any? { |e| wanted.include?(e) }
|
|
150
|
+
end.first(limit)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Rubino
|
|
7
|
+
module Memory
|
|
8
|
+
# Primary storage interface for persistent memories.
|
|
9
|
+
# Handles CRUD operations on the memories table.
|
|
10
|
+
class Store
|
|
11
|
+
VALID_KINDS = %w[
|
|
12
|
+
user_profile
|
|
13
|
+
preference
|
|
14
|
+
project_context
|
|
15
|
+
technical_decision
|
|
16
|
+
fact
|
|
17
|
+
task_state
|
|
18
|
+
tool_result
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# Raised when ThreatScanner flags content destined for the store.
|
|
22
|
+
# Carries the threat label so callers can branch on it without
|
|
23
|
+
# parsing the human-facing message.
|
|
24
|
+
class ThreatDetectedError < Rubino::Error
|
|
25
|
+
attr_reader :threat
|
|
26
|
+
|
|
27
|
+
def initialize(threat)
|
|
28
|
+
@threat = threat
|
|
29
|
+
super("memory threat detected: #{threat}")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Raised when adding `content` to `kind`'s group would push the
|
|
34
|
+
# group's total characters past the configured budget. Group rules
|
|
35
|
+
# live in Store#group_for_kind — user_profile is its own group,
|
|
36
|
+
# everything else shares the general "memory" budget.
|
|
37
|
+
class BudgetExceededError < Rubino::Error
|
|
38
|
+
attr_reader :group, :limit, :current, :requested
|
|
39
|
+
|
|
40
|
+
def initialize(group:, limit:, current:, requested:)
|
|
41
|
+
@group = group
|
|
42
|
+
@limit = limit
|
|
43
|
+
@current = current
|
|
44
|
+
@requested = requested
|
|
45
|
+
super("memory budget exceeded for #{group}: " \
|
|
46
|
+
"#{current}+#{requested} > #{limit}")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def initialize(db: nil, config: nil)
|
|
51
|
+
@db = db || Rubino.database.db
|
|
52
|
+
@config = config
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Creates a new memory entry.
|
|
56
|
+
#
|
|
57
|
+
# Two boundary checks run *before* the row is inserted:
|
|
58
|
+
# 1. ThreatScanner — refuses prompt-injection, exfil, invisible
|
|
59
|
+
# unicode, etc. Memory persists across sessions, so a tainted
|
|
60
|
+
# write would keep biasing every future prompt.
|
|
61
|
+
# 2. char-budget — refuses writes that would push the group's
|
|
62
|
+
# total past memory_char_limit / memory_user_char_limit. Lets
|
|
63
|
+
# callers (Tools::MemoryTool) surface a "delete or replace
|
|
64
|
+
# older entries first" message instead of silently truncating
|
|
65
|
+
# at read-time.
|
|
66
|
+
def create(kind:, content:, source_session_id: nil, confidence: 1.0, metadata: {})
|
|
67
|
+
validate_kind!(kind)
|
|
68
|
+
enforce_threat_scan!(content)
|
|
69
|
+
enforce_char_budget!(kind, content)
|
|
70
|
+
|
|
71
|
+
now = Time.now.utc.iso8601
|
|
72
|
+
id = SecureRandom.uuid
|
|
73
|
+
|
|
74
|
+
@db[:memories].insert(
|
|
75
|
+
id: id,
|
|
76
|
+
kind: kind,
|
|
77
|
+
content: content,
|
|
78
|
+
source_session_id: source_session_id,
|
|
79
|
+
confidence: confidence,
|
|
80
|
+
metadata_json: metadata.empty? ? nil : JSON.generate(metadata),
|
|
81
|
+
created_at: now,
|
|
82
|
+
updated_at: now
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
find(id)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Finds a memory by ID (supports prefix matching)
|
|
89
|
+
def find(id)
|
|
90
|
+
@db[:memories].where(Sequel.like(:id, "#{id}%")).first
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Lists memories with optional filters
|
|
94
|
+
def list(kind: nil, limit: 20)
|
|
95
|
+
dataset = @db[:memories].order(Sequel.desc(:created_at)).limit(limit)
|
|
96
|
+
dataset = dataset.where(kind: kind) if kind
|
|
97
|
+
dataset.all
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Updates a memory's content.
|
|
101
|
+
#
|
|
102
|
+
# Same two boundary checks as create — the replace path was a hole that
|
|
103
|
+
# let an agent rewrite a benign entry with prompt-injection / exfil
|
|
104
|
+
# content without going through ThreatScanner, and let a chain of
|
|
105
|
+
# replaces grow a group past its char budget one byte at a time. The
|
|
106
|
+
# budget check subtracts the old row's length before re-adding the new,
|
|
107
|
+
# otherwise a same-size edit would be flagged as over budget when it
|
|
108
|
+
# isn't.
|
|
109
|
+
def update(id, content:, confidence: nil)
|
|
110
|
+
existing = find(id)
|
|
111
|
+
enforce_threat_scan!(content)
|
|
112
|
+
enforce_char_budget_for_update!(existing, content) if existing
|
|
113
|
+
|
|
114
|
+
attrs = { content: content, updated_at: Time.now.utc.iso8601 }
|
|
115
|
+
attrs[:confidence] = confidence if confidence
|
|
116
|
+
@db[:memories].where(id: id).update(attrs)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Deletes a memory
|
|
120
|
+
def delete(id)
|
|
121
|
+
count = @db[:memories].where(Sequel.like(:id, "#{id}%")).delete
|
|
122
|
+
count > 0
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Returns memories of a specific kind
|
|
126
|
+
def by_kind(kind, limit: 50)
|
|
127
|
+
@db[:memories]
|
|
128
|
+
.where(kind: kind)
|
|
129
|
+
.order(Sequel.desc(:confidence), Sequel.desc(:created_at))
|
|
130
|
+
.limit(limit)
|
|
131
|
+
.all
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Returns all memories within the character limit
|
|
135
|
+
def within_limit(char_limit:)
|
|
136
|
+
memories = @db[:memories]
|
|
137
|
+
.order(Sequel.desc(:confidence), Sequel.desc(:updated_at))
|
|
138
|
+
.all
|
|
139
|
+
|
|
140
|
+
selected = []
|
|
141
|
+
total_chars = 0
|
|
142
|
+
|
|
143
|
+
memories.each do |m|
|
|
144
|
+
break if total_chars + m[:content].length > char_limit
|
|
145
|
+
|
|
146
|
+
selected << m
|
|
147
|
+
total_chars += m[:content].length
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
selected
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Returns the total count of stored memories
|
|
154
|
+
def count
|
|
155
|
+
@db[:memories].count
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Returns the budget group a kind belongs to:
|
|
159
|
+
# - "user" → user_profile (its own dedicated budget)
|
|
160
|
+
# - "memory" → everything else (shared general-memory budget)
|
|
161
|
+
def self.group_for_kind(kind)
|
|
162
|
+
kind == "user_profile" ? "user" : "memory"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Sum of content length across every row in the given group.
|
|
166
|
+
def total_chars_for_group(group)
|
|
167
|
+
if group == "user"
|
|
168
|
+
@db[:memories].where(kind: "user_profile").sum(Sequel.function(:length, :content)).to_i
|
|
169
|
+
else
|
|
170
|
+
@db[:memories].exclude(kind: "user_profile").sum(Sequel.function(:length, :content)).to_i
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def validate_kind!(kind)
|
|
177
|
+
return if VALID_KINDS.include?(kind)
|
|
178
|
+
|
|
179
|
+
raise Error, "Invalid memory kind: #{kind}. Valid: #{VALID_KINDS.join(", ")}"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def enforce_threat_scan!(content)
|
|
183
|
+
threat = ThreatScanner.scan(content)
|
|
184
|
+
return unless threat
|
|
185
|
+
|
|
186
|
+
begin
|
|
187
|
+
Rubino.logger.warn(event: "memory.threat_detected", threat: threat)
|
|
188
|
+
rescue StandardError
|
|
189
|
+
# logging must never block the refusal path
|
|
190
|
+
end
|
|
191
|
+
raise ThreatDetectedError.new(threat)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def enforce_char_budget!(kind, content)
|
|
195
|
+
cfg = @config || Rubino.configuration
|
|
196
|
+
group = self.class.group_for_kind(kind)
|
|
197
|
+
limit = group == "user" ? cfg.memory_user_char_limit : cfg.memory_char_limit
|
|
198
|
+
return unless limit && limit > 0
|
|
199
|
+
|
|
200
|
+
current = total_chars_for_group(group)
|
|
201
|
+
requested = content.to_s.length
|
|
202
|
+
return if current + requested <= limit
|
|
203
|
+
|
|
204
|
+
raise BudgetExceededError.new(
|
|
205
|
+
group: group, limit: limit, current: current, requested: requested
|
|
206
|
+
)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Update variant: subtract the row's current content length from the
|
|
210
|
+
# group total before checking the new one, so a same-size or smaller
|
|
211
|
+
# edit always passes even when the group is already at the limit.
|
|
212
|
+
def enforce_char_budget_for_update!(existing, new_content)
|
|
213
|
+
cfg = @config || Rubino.configuration
|
|
214
|
+
group = self.class.group_for_kind(existing[:kind])
|
|
215
|
+
limit = group == "user" ? cfg.memory_user_char_limit : cfg.memory_char_limit
|
|
216
|
+
return unless limit && limit > 0
|
|
217
|
+
|
|
218
|
+
current = total_chars_for_group(group) - existing[:content].to_s.length
|
|
219
|
+
requested = new_content.to_s.length
|
|
220
|
+
return if current + requested <= limit
|
|
221
|
+
|
|
222
|
+
raise BudgetExceededError.new(
|
|
223
|
+
group: group, limit: limit, current: current, requested: requested
|
|
224
|
+
)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|