protege 0.1.0.alpha.1
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/README.md +24 -0
- data/Rakefile +12 -0
- data/app/assets/builds/protege.css +1128 -0
- data/app/assets/javascripts/protege/attachment_picker.js +163 -0
- data/app/assets/javascripts/protege/copy.js +80 -0
- data/app/assets/javascripts/protege/forms.js +28 -0
- data/app/assets/javascripts/protege/keys.js +212 -0
- data/app/assets/javascripts/protege/resize.js +149 -0
- data/app/assets/javascripts/protege/theme.js +25 -0
- data/app/assets/stylesheets/protege/application.css +15 -0
- data/app/assets/tailwind/application.css +137 -0
- data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
- data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
- data/app/controllers/protege/access_rules_controller.rb +72 -0
- data/app/controllers/protege/application_controller.rb +18 -0
- data/app/controllers/protege/archives_controller.rb +52 -0
- data/app/controllers/protege/email_domains_controller.rb +72 -0
- data/app/controllers/protege/home_controller.rb +15 -0
- data/app/controllers/protege/messages_controller.rb +46 -0
- data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
- data/app/controllers/protege/personas_controller.rb +118 -0
- data/app/controllers/protege/replies_controller.rb +64 -0
- data/app/controllers/protege/responsibilities_controller.rb +82 -0
- data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
- data/app/controllers/protege/threads_controller.rb +75 -0
- data/app/controllers/protege/traces/searches_controller.rb +19 -0
- data/app/controllers/protege/traces_controller.rb +52 -0
- data/app/helpers/protege/application_helper.rb +30 -0
- data/app/helpers/protege/components/badges_helper.rb +59 -0
- data/app/helpers/protege/components/buttons_helper.rb +141 -0
- data/app/helpers/protege/components/cards_helper.rb +48 -0
- data/app/helpers/protege/components/components_helper.rb +30 -0
- data/app/helpers/protege/components/dialogs_helper.rb +36 -0
- data/app/helpers/protege/components/forms_helper.rb +223 -0
- data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
- data/app/helpers/protege/components/icons_helper.rb +41 -0
- data/app/helpers/protege/components/introspection_helper.rb +162 -0
- data/app/helpers/protege/components/layout_helper.rb +169 -0
- data/app/helpers/protege/components/menus_helper.rb +68 -0
- data/app/helpers/protege/components/pagination_helper.rb +100 -0
- data/app/helpers/protege/components/sidebar_helper.rb +211 -0
- data/app/helpers/protege/components/tables_helper.rb +59 -0
- data/app/helpers/protege/components/typography_helper.rb +64 -0
- data/app/helpers/protege/email_domains_helper.rb +22 -0
- data/app/helpers/protege/messages_helper.rb +152 -0
- data/app/helpers/protege/personas_helper.rb +90 -0
- data/app/helpers/protege/responsibilities_helper.rb +63 -0
- data/app/helpers/protege/threads_helper.rb +118 -0
- data/app/helpers/protege/trace_helper.rb +22 -0
- data/app/hooks/protege/event_logger_hook.rb +109 -0
- data/app/jobs/protege/application_job.rb +41 -0
- data/app/jobs/protege/console_inference_job.rb +57 -0
- data/app/jobs/protege/inference_job.rb +60 -0
- data/app/jobs/protege/mail_provisioning_job.rb +21 -0
- data/app/jobs/protege/responsibility_job.rb +70 -0
- data/app/jobs/protege/responsibility_tick_job.rb +19 -0
- data/app/mailboxes/protege/agent_mailbox.rb +123 -0
- data/app/mailers/protege/application_mailer.rb +65 -0
- data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
- data/app/models/concerns/protege/broadcastable_message.rb +47 -0
- data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
- data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
- data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
- data/app/models/concerns/protege/tool_scoped.rb +88 -0
- data/app/models/protege/access_rule.rb +50 -0
- data/app/models/protege/application_record.rb +18 -0
- data/app/models/protege/current.rb +23 -0
- data/app/models/protege/email_domain.rb +136 -0
- data/app/models/protege/email_thread.rb +81 -0
- data/app/models/protege/message.rb +401 -0
- data/app/models/protege/persona.rb +229 -0
- data/app/models/protege/responsibility.rb +98 -0
- data/app/models/protege/responsibility_run.rb +68 -0
- data/app/models/protege/tool_use.rb +115 -0
- data/app/models/protege/trace.rb +71 -0
- data/app/providers/protege/open_router_provider.rb +317 -0
- data/app/resolvers/README.md +255 -0
- data/app/resolvers/protege/load_file_resolver.rb +86 -0
- data/app/resolvers/protege/load_record_resolver.rb +80 -0
- data/app/resolvers/protege/load_text_resolver.rb +40 -0
- data/app/resolvers/protege/thread_history_resolver.rb +116 -0
- data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
- data/app/services/protege/message_search.rb +114 -0
- data/app/services/protege/trace_search.rb +102 -0
- data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
- data/app/tools/protege/create_file_tool.rb +109 -0
- data/app/tools/protege/read_attachment_tool.rb +101 -0
- data/app/tools/protege/search_emails_tool.rb +129 -0
- data/app/tools/protege/send_email_tool.rb +240 -0
- data/app/tools/protege/web_fetch_tool.rb +224 -0
- data/app/tools/protege/web_search_tool.rb +131 -0
- data/app/views/layouts/protege/application.html.slim +19 -0
- data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
- data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
- data/app/views/protege/email_domains/_form.html.slim +10 -0
- data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
- data/app/views/protege/email_domains/index.html.slim +6 -0
- data/app/views/protege/email_domains/new.html.slim +7 -0
- data/app/views/protege/email_domains/show.html.slim +36 -0
- data/app/views/protege/home/show.html.slim +26 -0
- data/app/views/protege/messages/_message.html.slim +19 -0
- data/app/views/protege/messages/_sidebar.html.slim +13 -0
- data/app/views/protege/messages/index.html.slim +20 -0
- data/app/views/protege/personas/_access_rule.html.slim +7 -0
- data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
- data/app/views/protege/personas/_access_rules.html.slim +19 -0
- data/app/views/protege/personas/_actions.html.slim +15 -0
- data/app/views/protege/personas/_form.html.slim +29 -0
- data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
- data/app/views/protege/personas/_sidebar.html.slim +11 -0
- data/app/views/protege/personas/edit.html.slim +7 -0
- data/app/views/protege/personas/index.html.slim +6 -0
- data/app/views/protege/personas/new.html.slim +7 -0
- data/app/views/protege/personas/show.html.slim +37 -0
- data/app/views/protege/responsibilities/_form.html.slim +21 -0
- data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
- data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
- data/app/views/protege/responsibilities/edit.html.slim +7 -0
- data/app/views/protege/responsibilities/index.html.slim +6 -0
- data/app/views/protege/responsibilities/new.html.slim +7 -0
- data/app/views/protege/responsibilities/show.html.slim +22 -0
- data/app/views/protege/responsibility_runs/show.html.slim +13 -0
- data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
- data/app/views/protege/shared/_header.html.slim +14 -0
- data/app/views/protege/shared/_hotkeys.html.slim +43 -0
- data/app/views/protege/threads/_composer_footer.html.slim +15 -0
- data/app/views/protege/threads/_introspection.html.slim +7 -0
- data/app/views/protege/threads/_reasoning.html.slim +4 -0
- data/app/views/protege/threads/_reply_form.html.slim +7 -0
- data/app/views/protege/threads/_run_marker.html.slim +3 -0
- data/app/views/protege/threads/_sidebar.html.slim +15 -0
- data/app/views/protege/threads/_thread.html.slim +6 -0
- data/app/views/protege/threads/_tool_call.html.slim +8 -0
- data/app/views/protege/threads/index.html.slim +6 -0
- data/app/views/protege/threads/new.html.slim +15 -0
- data/app/views/protege/threads/show.html.slim +25 -0
- data/app/views/protege/traces/_sidebar.html.slim +15 -0
- data/app/views/protege/traces/index.html.slim +11 -0
- data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
- data/app/views/protege/traces/searches/show.html.slim +22 -0
- data/app/views/protege/traces/show.html.slim +28 -0
- data/config/routes.rb +33 -0
- data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
- data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
- data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
- data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
- data/lib/generators/protege/extension_naming.rb +38 -0
- data/lib/generators/protege/hook/hook_generator.rb +33 -0
- data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
- data/lib/generators/protege/install/install_generator.rb +170 -0
- data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
- data/lib/generators/protege/persona/persona_generator.rb +35 -0
- data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
- data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
- data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
- data/lib/generators/protege/provider/provider_generator.rb +44 -0
- data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
- data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
- data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
- data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
- data/lib/generators/protege/tool/tool_generator.rb +34 -0
- data/lib/protege/configuration.rb +171 -0
- data/lib/protege/engine.rb +103 -0
- data/lib/protege/errors/bad_request_error.rb +8 -0
- data/lib/protege/errors/contract_violation_error.rb +8 -0
- data/lib/protege/errors/error.rb +30 -0
- data/lib/protege/errors/generic_error.rb +7 -0
- data/lib/protege/errors/internal_error.rb +8 -0
- data/lib/protege/errors/invalid_model_error.rb +8 -0
- data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
- data/lib/protege/errors/rate_limited_error.rb +8 -0
- data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
- data/lib/protege/errors/response_parse_failed_error.rb +8 -0
- data/lib/protege/errors/timeout_error.rb +8 -0
- data/lib/protege/errors/tool_not_available_error.rb +12 -0
- data/lib/protege/errors/tool_not_found_error.rb +9 -0
- data/lib/protege/errors/unauthorized_error.rb +8 -0
- data/lib/protege/errors/unavailable_error.rb +8 -0
- data/lib/protege/errors/unsupported_capability_error.rb +8 -0
- data/lib/protege/events/event.rb +98 -0
- data/lib/protege/events/inference_chunk_event.rb +17 -0
- data/lib/protege/events/inference_completed_event.rb +12 -0
- data/lib/protege/events/inference_failed_event.rb +12 -0
- data/lib/protege/events/inference_generated_event.rb +19 -0
- data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
- data/lib/protege/events/inference_started_event.rb +11 -0
- data/lib/protege/events/loop_run_completed_event.rb +11 -0
- data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
- data/lib/protege/events/loop_run_failed_event.rb +12 -0
- data/lib/protege/events/loop_run_started_event.rb +11 -0
- data/lib/protege/events/tool_call_completed_event.rb +12 -0
- data/lib/protege/events/tool_call_failed_event.rb +12 -0
- data/lib/protege/events/tool_call_started_event.rb +11 -0
- data/lib/protege/events/tool_calls_received_event.rb +11 -0
- data/lib/protege/extensions/hook.rb +13 -0
- data/lib/protege/extensions/hook_mixin.rb +90 -0
- data/lib/protege/extensions/provider.rb +12 -0
- data/lib/protege/extensions/provider_mixin.rb +124 -0
- data/lib/protege/extensions/resolver.rb +12 -0
- data/lib/protege/extensions/resolver_mixin.rb +58 -0
- data/lib/protege/extensions/tool.rb +12 -0
- data/lib/protege/extensions/tool_mixin.rb +217 -0
- data/lib/protege/gateway/access_control.rb +66 -0
- data/lib/protege/gateway/access_policy.rb +94 -0
- data/lib/protege/gateway/attachment_policy.rb +115 -0
- data/lib/protege/gateway/mail/address.rb +145 -0
- data/lib/protege/gateway/mail/attachment.rb +17 -0
- data/lib/protege/gateway/mail/message_id.rb +104 -0
- data/lib/protege/gateway/mail/outbound.rb +181 -0
- data/lib/protege/gateway/mail/references.rb +107 -0
- data/lib/protege/gateway.rb +398 -0
- data/lib/protege/inference/provider/request.rb +110 -0
- data/lib/protege/inference/provider/response.rb +50 -0
- data/lib/protege/inference/provider/tool_call.rb +52 -0
- data/lib/protege/inference.rb +121 -0
- data/lib/protege/loop/schedule.rb +66 -0
- data/lib/protege/loop/scheduler.rb +42 -0
- data/lib/protege/loop.rb +37 -0
- data/lib/protege/orchestrator/context.rb +65 -0
- data/lib/protege/orchestrator/harness.rb +305 -0
- data/lib/protege/orchestrator/reply_context.rb +21 -0
- data/lib/protege/orchestrator/reply_harness.rb +68 -0
- data/lib/protege/orchestrator/resolver_chain.rb +93 -0
- data/lib/protege/orchestrator/responsibility_context.rb +22 -0
- data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
- data/lib/protege/orchestrator.rb +37 -0
- data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
- data/lib/protege/subscribers/tracing.rb +65 -0
- data/lib/protege/types/document_part.rb +13 -0
- data/lib/protege/types/image_part.rb +11 -0
- data/lib/protege/types/model_message.rb +40 -0
- data/lib/protege/types/result.rb +113 -0
- data/lib/protege/types/stored_file.rb +127 -0
- data/lib/protege/types/text_part.rb +8 -0
- data/lib/protege/types/tool_result.rb +16 -0
- data/lib/protege/version.rb +6 -0
- data/lib/protege.rb +47 -0
- data/lib/tasks/protege_tasks.rake +16 -0
- metadata +494 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Persisted record of a single email — inbound or outbound — in the Protege pipeline.
|
|
5
|
+
#
|
|
6
|
+
# +Message+ is the application's source of truth for everything the engine reasons about: the web
|
|
7
|
+
# UI, the inbox, and the +ThreadHistoryResolver+ resolver all read from this model. The underlying
|
|
8
|
+
# +ActionMailbox::InboundEmail+ is retained only as a raw delivery-pipeline artifact; application
|
|
9
|
+
# logic never queries it directly. Every message belongs to exactly one +EmailThread+ and one
|
|
10
|
+
# +Persona+, and inbound messages link back to the +InboundEmail+ they were ingested from.
|
|
11
|
+
#
|
|
12
|
+
# == Lifecycle (inbound)
|
|
13
|
+
#
|
|
14
|
+
# pending → received, InferenceJob not yet queued
|
|
15
|
+
# processing → InferenceJob is running
|
|
16
|
+
# processed → InferenceJob completed successfully
|
|
17
|
+
# failed → InferenceJob raised; error captured in +last_processing_error+
|
|
18
|
+
# bounced → no matching persona; handled in AgentMailbox before a job is queued
|
|
19
|
+
#
|
|
20
|
+
# == Lifecycle (outbound)
|
|
21
|
+
#
|
|
22
|
+
# Outbound messages are persisted by +MessageObserver+ once delivery succeeds and carry no
|
|
23
|
+
# +processing_status+ — they are terminal records of what the agent sent.
|
|
24
|
+
class Message < ApplicationRecord
|
|
25
|
+
# ── Concerns ────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
include BroadcastableMessage
|
|
28
|
+
|
|
29
|
+
# ── Associations ────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
belongs_to :persona, class_name: 'Protege::Persona'
|
|
32
|
+
belongs_to :email_thread, class_name: 'Protege::EmailThread', inverse_of: :messages
|
|
33
|
+
# Optional: only inbound messages originate from an ActionMailbox record.
|
|
34
|
+
belongs_to :inbound_email, class_name: 'ActionMailbox::InboundEmail', optional: true
|
|
35
|
+
|
|
36
|
+
has_many_attached :attachments
|
|
37
|
+
|
|
38
|
+
# Tool calls+results the agent recorded while replying to this message; replayed by
|
|
39
|
+
# +ThreadHistoryResolver+ and destroyed with the message.
|
|
40
|
+
has_many :tool_uses, as: :source, class_name: 'Protege::ToolUse', dependent: :destroy
|
|
41
|
+
|
|
42
|
+
# ── Serialization ───────────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
# Recipient lists are stored as JSON arrays so multi-recipient headers round-trip intact.
|
|
45
|
+
with_options coder: JSON do
|
|
46
|
+
serialize :to_addresses
|
|
47
|
+
serialize :cc_addresses
|
|
48
|
+
serialize :bcc_addresses
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ── Enums ───────────────────────────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
# Whether the message was received by or sent from the engine.
|
|
54
|
+
enum :direction, { inbound: 'inbound', outbound: 'outbound' }
|
|
55
|
+
|
|
56
|
+
# Inbound-only processing state machine; see the lifecycle notes on the class.
|
|
57
|
+
enum :processing_status, {
|
|
58
|
+
pending: 'pending',
|
|
59
|
+
processing: 'processing',
|
|
60
|
+
processed: 'processed',
|
|
61
|
+
failed: 'failed',
|
|
62
|
+
bounced: 'bounced'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
# ── Validations ─────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
# Message-ID is the dedup key; uniqueness keeps retries idempotent.
|
|
68
|
+
validates :message_id, presence: true, uniqueness: true
|
|
69
|
+
validates :direction, presence: true
|
|
70
|
+
validates :from_address, presence: true
|
|
71
|
+
validates :sent_at, presence: true
|
|
72
|
+
|
|
73
|
+
# ── Scopes ──────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
# Messages the operator has not yet opened in the UI.
|
|
76
|
+
scope :unread, -> { where(read_at: nil) }
|
|
77
|
+
|
|
78
|
+
# ── Callbacks ───────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
# Refresh the thread's denormalised counters once the new message is committed.
|
|
81
|
+
after_create_commit :update_thread_stats
|
|
82
|
+
|
|
83
|
+
# ── Class methods ───────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
class << self
|
|
86
|
+
# Ingest an +ActionMailbox::InboundEmail+ into a persisted, processing +Message+.
|
|
87
|
+
#
|
|
88
|
+
# Finds or creates the owning +EmailThread+, copies all mail-derived attributes onto the
|
|
89
|
+
# record, and transitions it to +processing+. Keyed on +inbound_email_id+, so calling it
|
|
90
|
+
# again for the same inbound email updates the existing record rather than duplicating it —
|
|
91
|
+
# making it safe to invoke on job retry.
|
|
92
|
+
#
|
|
93
|
+
# @param inbound_email [ActionMailbox::InboundEmail] the raw received email
|
|
94
|
+
# @param persona [Protege::Persona] the persona the email routed to
|
|
95
|
+
# @return [Protege::Message] the saved message in the +processing+ state
|
|
96
|
+
def ingest!(inbound_email:, persona:)
|
|
97
|
+
mail = inbound_email.mail
|
|
98
|
+
thread = EmailThread.find_or_create_for(mail:, persona:)
|
|
99
|
+
|
|
100
|
+
find_or_initialize_by(inbound_email_id: inbound_email.id).tap do |record|
|
|
101
|
+
record.assign_from_mail(mail:, thread:, persona:, inbound_email:)
|
|
102
|
+
record.start_processing!
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Build (but do not save) a +Message+ from a +Mail::Message+ object.
|
|
107
|
+
#
|
|
108
|
+
# Used for outbound messages constructed outside the mailbox ingress path. The caller owns
|
|
109
|
+
# any further field assignment and the eventual +save!+.
|
|
110
|
+
#
|
|
111
|
+
# @param mail [Mail::Message] the parsed email to copy attributes from
|
|
112
|
+
# @param direction [Symbol, String] +:inbound+ or +:outbound+
|
|
113
|
+
# @param thread [Protege::EmailThread] the owning thread
|
|
114
|
+
# @param persona [Protege::Persona] the persona the message belongs to
|
|
115
|
+
# @param inbound_email [ActionMailbox::InboundEmail, nil] the source record, if any
|
|
116
|
+
# @param attach_from_mail [Boolean] rebuild attachments from the mail's parts (true for inbound,
|
|
117
|
+
# whose files arrive as bytes); pass false for outbound, where the caller attaches known blobs.
|
|
118
|
+
# @return [Protege::Message] an unsaved message with attributes assigned
|
|
119
|
+
def build_from_mail(mail:, direction:, thread:, persona:, inbound_email: nil, attach_from_mail: true)
|
|
120
|
+
new.tap do |record|
|
|
121
|
+
record.assign_from_mail(mail:, thread:, persona:, inbound_email:, attach_from_mail:)
|
|
122
|
+
record.direction = direction
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
# Extract the decoded plain-text body from a mail object.
|
|
129
|
+
#
|
|
130
|
+
# Prefers the +text/plain+ part of a multipart message; for a single-part message, returns
|
|
131
|
+
# the decoded body only when its content type is +text/plain+.
|
|
132
|
+
#
|
|
133
|
+
# @param mail [Mail::Message] the email to read
|
|
134
|
+
# @return [String, nil] the decoded plain-text body, or nil when absent
|
|
135
|
+
def extract_text_body(mail)
|
|
136
|
+
if mail.multipart?
|
|
137
|
+
mail.text_part&.decoded
|
|
138
|
+
else
|
|
139
|
+
mail.content_type&.start_with?('text/plain') ? mail.decoded : nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Extract the decoded HTML body from a mail object.
|
|
144
|
+
#
|
|
145
|
+
# Prefers the +text/html+ part of a multipart message; for a single-part message, returns the
|
|
146
|
+
# decoded body only when its content type is +text/html+.
|
|
147
|
+
#
|
|
148
|
+
# @param mail [Mail::Message] the email to read
|
|
149
|
+
# @return [String, nil] the decoded HTML body, or nil when absent
|
|
150
|
+
def extract_html_body(mail)
|
|
151
|
+
if mail.multipart?
|
|
152
|
+
mail.html_part&.decoded
|
|
153
|
+
else
|
|
154
|
+
mail.content_type&.start_with?('text/html') ? mail.decoded : nil
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# ── Instance methods ────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
# The message's best plain-text content for replay or display.
|
|
162
|
+
#
|
|
163
|
+
# Prefers the plain-text body; when that is blank, falls back to the HTML body with its tags
|
|
164
|
+
# stripped, so an HTML-only email still yields readable text. Returns +nil+ when the message
|
|
165
|
+
# carries neither — letting callers (e.g. the +ThreadHistoryResolver+ resolver) skip empty turns.
|
|
166
|
+
#
|
|
167
|
+
# @return [String, nil] the readable body, or +nil+ when both bodies are blank
|
|
168
|
+
def readable_body
|
|
169
|
+
text = text_body.to_s.strip
|
|
170
|
+
return text unless text.empty?
|
|
171
|
+
|
|
172
|
+
html = ActionController::Base.helpers.strip_tags(html_body.to_s).strip
|
|
173
|
+
html unless html.empty?
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The standard email-style header block for this message: From / To / Cc / Date / Subject, one line
|
|
177
|
+
# each, in that fixed order. Every line is always rendered — a field with no value is shown blank
|
|
178
|
+
# (e.g. +Cc:+), never omitted — so every turn has the same predictable shape for the model. Folded
|
|
179
|
+
# into {#to_llm_text} as plain text, deliberately kept in the +content+ string rather than added as
|
|
180
|
+
# new message metadata.
|
|
181
|
+
#
|
|
182
|
+
# @return [String] the five-line header block
|
|
183
|
+
def content_header
|
|
184
|
+
[
|
|
185
|
+
"From: #{from_address}",
|
|
186
|
+
"To: #{Array(to_addresses).join(', ')}",
|
|
187
|
+
"Cc: #{Array(cc_addresses).join(', ')}",
|
|
188
|
+
"Date: #{sent_at&.utc&.strftime('%Y-%m-%d %H:%M UTC')}",
|
|
189
|
+
"Subject: #{subject}"
|
|
190
|
+
].join("\n")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The canonical plain-text serialization of this message for the model: the standard
|
|
194
|
+
# {#content_header} block, the readable body, and — when the message has attachments — the note the
|
|
195
|
+
# app already builds for them (see {#attachment_parts_with_note}). This is the single "message as
|
|
196
|
+
# text" artifact both the reply opening turn and the +ThreadHistoryResolver+ replay, so a turn is
|
|
197
|
+
# self-describing. Always includes the header; callers deciding whether to skip an empty turn test
|
|
198
|
+
# the message's body/attachments, not this.
|
|
199
|
+
#
|
|
200
|
+
# @return [String] the header, body, and attachment note joined into one string
|
|
201
|
+
def to_llm_text
|
|
202
|
+
note = attachment_parts_with_note.last.text if attachments.attached?
|
|
203
|
+
compose_llm_text(note:)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# This message expressed as provider content — the reply path's opening user turn. The text is
|
|
207
|
+
# {#to_llm_text} (header + body + attachment note); when the message carries viewable attachments
|
|
208
|
+
# (images/PDFs) their vision parts are appended so a multimodal model sees the files themselves.
|
|
209
|
+
# Returns a plain string when there are no attachments.
|
|
210
|
+
#
|
|
211
|
+
# @return [String, Array<Object>] the serialized text, or that text plus attachment vision parts
|
|
212
|
+
def content_parts
|
|
213
|
+
return to_llm_text unless attachments.attached?
|
|
214
|
+
|
|
215
|
+
vision, note = attachment_parts_with_note
|
|
216
|
+
[Protege::TextPart.new(text: compose_llm_text(note: note.text)), *vision]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Walk this message's attachments once, returning +[[vision_parts], note_part]+ — each viewable file
|
|
220
|
+
# rendered as its vision part (via {Protege::StoredFile#to_part}) and a single trailing
|
|
221
|
+
# {Protege::TextPart} note naming every attachment (unviewable ones included) with its blob id.
|
|
222
|
+
# Callers flatten both into content, or take just the note part (e.g. thread-history replay).
|
|
223
|
+
#
|
|
224
|
+
# @return [Array(Array<Object>, Protege::TextPart)] the vision parts and the note part
|
|
225
|
+
def attachment_parts_with_note
|
|
226
|
+
vision = []
|
|
227
|
+
entries = []
|
|
228
|
+
|
|
229
|
+
attachments.each do |attachment|
|
|
230
|
+
file = Protege::StoredFile.new(attachment.blob)
|
|
231
|
+
vision << file.to_part if file.viewable?
|
|
232
|
+
entries << file.summary
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
[vision, Protege::TextPart.new(text: attachment_note_text(entries))]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Copy every RFC 2822-derived attribute from a +Mail::Message+ onto this record.
|
|
239
|
+
#
|
|
240
|
+
# Shared by both {.ingest!} and {.build_from_mail}. Sets the thread/persona associations and
|
|
241
|
+
# then delegates the header, body, and threading field groups to private helpers so the work
|
|
242
|
+
# stays within Active Record's metrics budget. Does not save.
|
|
243
|
+
#
|
|
244
|
+
# @param mail [Mail::Message] the parsed email to read attributes from
|
|
245
|
+
# @param thread [Protege::EmailThread] the owning thread
|
|
246
|
+
# @param persona [Protege::Persona] the persona the message belongs to
|
|
247
|
+
# @param inbound_email [ActionMailbox::InboundEmail, nil] the source record, if any
|
|
248
|
+
# @param attach_from_mail [Boolean] rebuild attachments from the mail's MIME parts; true for inbound
|
|
249
|
+
# (files arrive as bytes), false for outbound (the caller attaches the known blobs by reference).
|
|
250
|
+
# @return [void]
|
|
251
|
+
def assign_from_mail(mail:, thread:, persona:, inbound_email: nil, attach_from_mail: true)
|
|
252
|
+
self.email_thread = thread
|
|
253
|
+
self.persona = persona
|
|
254
|
+
self.inbound_email = inbound_email
|
|
255
|
+
|
|
256
|
+
assign_envelope_from(mail)
|
|
257
|
+
assign_threading_from(mail)
|
|
258
|
+
assign_bodies_from(mail)
|
|
259
|
+
assign_attachments_from(mail) if attach_from_mail
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Mark this message as in-progress and persist it.
|
|
263
|
+
#
|
|
264
|
+
# Called at the start of inference (by +InferenceJob+ via {.ingest!}, and by
|
|
265
|
+
# +ConsoleInferenceJob+ directly). Defaults the direction to +:inbound+, stamps the start time,
|
|
266
|
+
# increments the attempt counter, and captures the current correlation ID for tracing.
|
|
267
|
+
#
|
|
268
|
+
# @return [void]
|
|
269
|
+
# @raise [ActiveRecord::RecordInvalid] when the record fails validation
|
|
270
|
+
def start_processing!
|
|
271
|
+
self.direction = direction || :inbound
|
|
272
|
+
self.processing_status = :processing
|
|
273
|
+
self.processing_started_at = Time.current
|
|
274
|
+
self.processing_attempts = processing_attempts.to_i + 1
|
|
275
|
+
self.correlation_id = Protege::Current.correlation_id
|
|
276
|
+
save!
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Transition the message to +processed+ and record the completion time.
|
|
280
|
+
#
|
|
281
|
+
# @return [void]
|
|
282
|
+
# @raise [ActiveRecord::RecordInvalid] when the update fails validation
|
|
283
|
+
def mark_processed!
|
|
284
|
+
update!(processing_status: :processed, processing_completed_at: Time.current)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Transition the message to +failed+ and capture the error for later inspection.
|
|
288
|
+
#
|
|
289
|
+
# Stores the error class, message, and the first five backtrace lines in
|
|
290
|
+
# +last_processing_error+ so a failed job can be triaged without re-running it.
|
|
291
|
+
#
|
|
292
|
+
# @param error [Exception] the error raised during processing
|
|
293
|
+
# @return [void]
|
|
294
|
+
# @raise [ActiveRecord::RecordInvalid] when the update fails validation
|
|
295
|
+
def mark_failed!(error)
|
|
296
|
+
update!(
|
|
297
|
+
processing_status: :failed,
|
|
298
|
+
last_processing_error: "#{error.class}: #{error.message}\n#{error.backtrace.first(5).join("\n")}"
|
|
299
|
+
)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
private
|
|
303
|
+
|
|
304
|
+
# Assemble {#to_llm_text} from the standard header, the readable body (when present), and an optional
|
|
305
|
+
# attachment note. The header is always included, so the result is never blank. The note is passed in
|
|
306
|
+
# so a caller that already walked the attachments (see {#content_parts}) need not walk them twice.
|
|
307
|
+
#
|
|
308
|
+
# @param note [String, nil] the attachment note text, when the message has attachments
|
|
309
|
+
# @return [String] the composed text
|
|
310
|
+
def compose_llm_text(note:)
|
|
311
|
+
[content_header, readable_body.presence, note].compact.join("\n\n")
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Assign the envelope (addressing) headers from a mail object.
|
|
315
|
+
#
|
|
316
|
+
# Addresses are stored as received (the Mail gem already drops display names; case is preserved)
|
|
317
|
+
# since these columns are presentation values — normalization for routing happens on demand via
|
|
318
|
+
# +Gateway::Mail::Address+, never at write time.
|
|
319
|
+
#
|
|
320
|
+
# @param mail [Mail::Message] the email to read
|
|
321
|
+
# @return [void]
|
|
322
|
+
def assign_envelope_from(mail)
|
|
323
|
+
self.from_address = Array(mail.from).first.to_s
|
|
324
|
+
self.to_addresses = Array(mail.to)
|
|
325
|
+
self.cc_addresses = Array(mail.cc)
|
|
326
|
+
self.bcc_addresses = Array(mail.bcc)
|
|
327
|
+
self.reply_to = Array(mail.reply_to).first
|
|
328
|
+
self.subject = mail.subject.to_s
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Assign the identity and threading headers (Message-ID, In-Reply-To, References) from a mail
|
|
332
|
+
# object, normalized through +Gateway::Mail+ to canonical angle-bracketed form.
|
|
333
|
+
#
|
|
334
|
+
# @param mail [Mail::Message] the email to read
|
|
335
|
+
# @return [void]
|
|
336
|
+
def assign_threading_from(mail)
|
|
337
|
+
self.message_id = Gateway.canonical_message_id(mail.message_id)
|
|
338
|
+
self.in_reply_to = Gateway.parse_message_id(mail.in_reply_to.presence)
|
|
339
|
+
self.references_header = Gateway.reference_ids(mail.references).join(' ').presence
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Assign the decoded bodies and the sent timestamp from a mail object.
|
|
343
|
+
#
|
|
344
|
+
# Falls back to the current time when the email carries no Date header.
|
|
345
|
+
#
|
|
346
|
+
# @param mail [Mail::Message] the email to read
|
|
347
|
+
# @return [void]
|
|
348
|
+
def assign_bodies_from(mail)
|
|
349
|
+
self.text_body = self.class.send(:extract_text_body, mail)
|
|
350
|
+
self.html_body = self.class.send(:extract_html_body, mail)
|
|
351
|
+
self.sent_at = mail.date&.to_time || Time.current
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# Persist the mail's attachments onto this record's Active Storage collection.
|
|
355
|
+
#
|
|
356
|
+
# Each +Mail+ attachment part is attached with its original filename and content type, decoding the
|
|
357
|
+
# part's transfer encoding via +#decoded+. The bare +#mime_type+ is stored (not +#content_type+,
|
|
358
|
+
# which carries header parameters like +; name="photo.jpg"+) so downstream type checks match cleanly.
|
|
359
|
+
# Skipped entirely when the record already has attachments, so re-running ingestion (which is
|
|
360
|
+
# retry-safe and keyed on +inbound_email_id+) never duplicates them. Inline parts are persisted
|
|
361
|
+
# alongside regular ones — the engine does not distinguish them yet.
|
|
362
|
+
#
|
|
363
|
+
# @param mail [Mail::Message] the email to read attachments from
|
|
364
|
+
# @return [void]
|
|
365
|
+
def assign_attachments_from(mail)
|
|
366
|
+
return if attachments.attached?
|
|
367
|
+
return if mail.attachments.blank?
|
|
368
|
+
|
|
369
|
+
mail.attachments.each do |part|
|
|
370
|
+
attachments.attach(
|
|
371
|
+
io: StringIO.new(part.decoded),
|
|
372
|
+
filename: part.filename,
|
|
373
|
+
content_type: part.mime_type
|
|
374
|
+
)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Format the bracketed attachment listing for {#attachment_parts_with_note} from a list of per-file
|
|
379
|
+
# summaries — a concise line of every attachment (filename, type, human size, and id) so the model
|
|
380
|
+
# knows what arrived, including unviewable files, and the ids the tools accept.
|
|
381
|
+
#
|
|
382
|
+
# @param entries [Array<String>] each attachment's {Protege::StoredFile#summary}
|
|
383
|
+
# @return [String] the joined "[Attachments: …]" note
|
|
384
|
+
def attachment_note_text(entries)
|
|
385
|
+
"[Attachments: #{entries.join('; ')}]"
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Keep the owning thread's denormalised counters current after a new message commits.
|
|
389
|
+
#
|
|
390
|
+
# Uses +update_columns+ to bump +last_message_at+ and recount messages without firing further
|
|
391
|
+
# callbacks or validations.
|
|
392
|
+
#
|
|
393
|
+
# @return [void]
|
|
394
|
+
def update_thread_stats
|
|
395
|
+
email_thread.update_columns(
|
|
396
|
+
last_message_at: Time.current,
|
|
397
|
+
message_count: email_thread.messages.count
|
|
398
|
+
)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
end
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Active Record base class for every Protege persona — the agent identity behind an email address.
|
|
5
|
+
#
|
|
6
|
+
# Personas are single-table-inheritance (STI) records: each concrete agent is a subclass defined
|
|
7
|
+
# in the host application and persisted in +protege_personas+, discriminated by the +type+ column.
|
|
8
|
+
# A persona owns the conversations addressed to it (+EmailThread+, +Message+) and declares the
|
|
9
|
+
# resolver chain that the Orchestrator runs to build the inference context for each turn.
|
|
10
|
+
#
|
|
11
|
+
# The +email_address+ is operator-chosen and must line up with the host's MX records. Inbound
|
|
12
|
+
# routing supports RFC 5233 subaddressing: +alice+thread123@example.com+ routes to the persona
|
|
13
|
+
# whose canonical address is +alice@example.com+ (see {.lookup}).
|
|
14
|
+
#
|
|
15
|
+
# @example Define a persona in the host application
|
|
16
|
+
# class Agent < Protege::Persona
|
|
17
|
+
# self.display_name = 'AI Agent'
|
|
18
|
+
#
|
|
19
|
+
# resolvers do |chain|
|
|
20
|
+
# chain.use Protege::ThreadHistoryResolver
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
class Persona < ApplicationRecord
|
|
24
|
+
self.table_name = 'protege_personas'
|
|
25
|
+
|
|
26
|
+
# Keep the Personas sidebar live (append/replace/remove).
|
|
27
|
+
include BroadcastablePersona
|
|
28
|
+
|
|
29
|
+
# Narrow the tool catalogue per persona: the code-declared +tools+ grant minus the operator's
|
|
30
|
+
# +disabled_tool_ids+ override (see +ToolScoped+).
|
|
31
|
+
include ToolScoped
|
|
32
|
+
|
|
33
|
+
# ── Associations ──────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
with_options dependent: :destroy do
|
|
36
|
+
# The persona's runtime inbound access rules — the dashboard-editable, narrowing layer of the
|
|
37
|
+
# access-control guardrail (see +AccessRule+ and +AccessControl+). Destroyed with the persona.
|
|
38
|
+
has_many :access_rules, class_name: 'Protege::AccessRule'
|
|
39
|
+
|
|
40
|
+
# Every email the persona has sent or received. Each +Message+ carries a denormalised
|
|
41
|
+
# +persona_id+, so this is the scoping seam for per-persona reads such as the +SearchEmailsTool+
|
|
42
|
+
# tool. Destroyed with the persona.
|
|
43
|
+
has_many :messages, class_name: 'Protege::Message'
|
|
44
|
+
|
|
45
|
+
# The persona's standing, cron-scheduled duties — the Loop layer (see +Responsibility+). Dashboard
|
|
46
|
+
# CRUD is nested under the persona; runs the duty's +responsibility_resolvers+ chain. Destroyed
|
|
47
|
+
# with the persona.
|
|
48
|
+
has_many :responsibilities, class_name: 'Protege::Responsibility'
|
|
49
|
+
|
|
50
|
+
# Every conversation addressed to the persona. Carries a +persona_id+ FK with no DB-level cascade,
|
|
51
|
+
# so it must be destroyed with the persona (its messages cascade in turn) — otherwise a permanent
|
|
52
|
+
# delete would orphan threads / raise a foreign-key error.
|
|
53
|
+
has_many :email_threads, class_name: 'Protege::EmailThread'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# ── Validations ─────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
with_options presence: true do
|
|
59
|
+
# STI discriminator must be present so the correct subclass is instantiated on load.
|
|
60
|
+
validates :type
|
|
61
|
+
validates :name, uniqueness: true
|
|
62
|
+
|
|
63
|
+
# Address must be unique and well-formed; it is the routing key for inbound mail.
|
|
64
|
+
validates :email_address, uniqueness: true,
|
|
65
|
+
format: { with: URI::MailTo::EMAIL_REGEXP }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# ── Scopes ──────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
default_scope { order(:name) }
|
|
71
|
+
|
|
72
|
+
# Only personas currently enabled to receive and answer mail (i.e. not archived).
|
|
73
|
+
scope :active, -> { where(archived_at: nil) }
|
|
74
|
+
|
|
75
|
+
# Personas that have been archived (soft-removed): kept for history, out of routing.
|
|
76
|
+
scope :archived, -> { where.not(archived_at: nil) }
|
|
77
|
+
|
|
78
|
+
# ── Display name ────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
# Backing store for the human label. Held on a +class_attribute+ rather than an instance
|
|
81
|
+
# variable so Active Record's schema-loading callbacks cannot reset it between requests.
|
|
82
|
+
class_attribute :_display_name, instance_accessor: false
|
|
83
|
+
|
|
84
|
+
# ── Class methods ───────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
class << self
|
|
87
|
+
# Return the human-readable label for this persona subclass.
|
|
88
|
+
#
|
|
89
|
+
# Falls back to the demodulized class name when no explicit label has been set, so an
|
|
90
|
+
# unconfigured +Foo::Agent+ subclass reads as +"Agent"+.
|
|
91
|
+
#
|
|
92
|
+
# @return [String, nil] the configured label, or the demodulized class name
|
|
93
|
+
def display_name
|
|
94
|
+
_display_name || name&.demodulize
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Set the human-readable label for this persona subclass.
|
|
98
|
+
#
|
|
99
|
+
# @param val [String] the label to display in the UI
|
|
100
|
+
# @return [String] the assigned label
|
|
101
|
+
def display_name=(val)
|
|
102
|
+
self._display_name = val
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Return the resolver chain that assembles context for threaded email replies — the reactive
|
|
106
|
+
# path — optionally mutating it in a block.
|
|
107
|
+
#
|
|
108
|
+
# Each subclass lazily builds and owns its own +Orchestrator::ResolverChain+; chains are not
|
|
109
|
+
# shared with the parent class. Passing a block yields the chain so resolvers can be appended.
|
|
110
|
+
#
|
|
111
|
+
# @yieldparam chain [Orchestrator::ResolverChain] the subclass's reply chain
|
|
112
|
+
# @return [Orchestrator::ResolverChain] the (possibly mutated) chain
|
|
113
|
+
def message_resolvers(&block)
|
|
114
|
+
@message_resolvers ||= Orchestrator::ResolverChain.new
|
|
115
|
+
block&.call(@message_resolvers)
|
|
116
|
+
@message_resolvers
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Append a single resolver to the reply chain.
|
|
120
|
+
#
|
|
121
|
+
# @param klass [Class] the resolver class to register
|
|
122
|
+
# @param args [Array] positional arguments forwarded to the resolver
|
|
123
|
+
# @param kwargs [Hash] keyword arguments forwarded to the resolver
|
|
124
|
+
# @return [Class] self, to allow chaining further +message_resolver+ calls
|
|
125
|
+
def message_resolver(klass, *args, **kwargs)
|
|
126
|
+
message_resolvers { |chain| chain.use(klass, *args, **kwargs) }
|
|
127
|
+
self
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Return the resolver chain that assembles context for scheduled responsibility runs — the
|
|
131
|
+
# proactive path (the Loop layer) — optionally mutating it in a block.
|
|
132
|
+
#
|
|
133
|
+
# Held separately from the reply chain so a persona can present different context when acting on
|
|
134
|
+
# its own initiative (e.g. a task prompt, no inbound thread) than when answering mail. Like the
|
|
135
|
+
# reply chain it is lazily built and owned per subclass, never shared with the parent.
|
|
136
|
+
#
|
|
137
|
+
# @yieldparam chain [Orchestrator::ResolverChain] the subclass's responsibility chain
|
|
138
|
+
# @return [Orchestrator::ResolverChain] the (possibly mutated) chain
|
|
139
|
+
def responsibility_resolvers(&block)
|
|
140
|
+
@responsibility_resolvers ||= Orchestrator::ResolverChain.new
|
|
141
|
+
block&.call(@responsibility_resolvers)
|
|
142
|
+
@responsibility_resolvers
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Append a single resolver to the responsibility chain.
|
|
146
|
+
#
|
|
147
|
+
# @param klass [Class] the resolver class to register
|
|
148
|
+
# @param args [Array] positional arguments forwarded to the resolver
|
|
149
|
+
# @param kwargs [Hash] keyword arguments forwarded to the resolver
|
|
150
|
+
# @return [Class] self, to allow chaining further +responsibility_resolver+ calls
|
|
151
|
+
def responsibility_resolver(klass, *args, **kwargs)
|
|
152
|
+
responsibility_resolvers { |chain| chain.use(klass, *args, **kwargs) }
|
|
153
|
+
self
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Find the active persona that owns the given address, honouring subaddressing.
|
|
157
|
+
#
|
|
158
|
+
# Parses the address through +Gateway::Mail::Address+ and matches its +routing_key+ (the
|
|
159
|
+
# tag-stripped, lowercased +local@domain+) against the canonical +email_address+. So
|
|
160
|
+
# +Alice+thread9@Example.com+ resolves to the persona at +alice@example.com+. Returns nil for
|
|
161
|
+
# blank or malformed input.
|
|
162
|
+
#
|
|
163
|
+
# @param address [String, nil] the recipient address from an inbound email
|
|
164
|
+
# @return [Protege::Persona, nil] the matching active persona, or nil when none routes
|
|
165
|
+
def lookup(address)
|
|
166
|
+
active.find_by(email_address: Gateway.routing_key(address))
|
|
167
|
+
rescue ArgumentError
|
|
168
|
+
nil
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# ── Instance methods ────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
# Return this persona's resolver chains by delegating to the class-level chains — the reply chain
|
|
175
|
+
# (+message_resolvers+) and the scheduled-run chain (+responsibility_resolvers+).
|
|
176
|
+
#
|
|
177
|
+
# @return [Orchestrator::ResolverChain] the chain declared on this persona's subclass
|
|
178
|
+
delegate :message_resolvers, :responsibility_resolvers, to: :class
|
|
179
|
+
|
|
180
|
+
# ── Archive state ─────────────────────────────────────────────────────────
|
|
181
|
+
# +active+/+archived+ are derived from +archived_at+ (no boolean column): +active?+ is what inbound
|
|
182
|
+
# routing (+.active+ scope, {.lookup}) and the status dot read, so archiving a persona removes it from
|
|
183
|
+
# service while keeping all its history.
|
|
184
|
+
|
|
185
|
+
# Whether the persona is active (not archived).
|
|
186
|
+
#
|
|
187
|
+
# @return [Boolean]
|
|
188
|
+
def active?
|
|
189
|
+
archived_at.nil?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Whether the persona has been archived (soft-removed): out of routing + scheduled runs, data kept.
|
|
193
|
+
#
|
|
194
|
+
# @return [Boolean]
|
|
195
|
+
def archived?
|
|
196
|
+
archived_at.present?
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Archive the persona — stops inbound routing and scheduled responsibility runs; data is preserved.
|
|
200
|
+
#
|
|
201
|
+
# @return [void]
|
|
202
|
+
def archive!
|
|
203
|
+
update!(archived_at: Time.current)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Return an archived persona to active service.
|
|
207
|
+
#
|
|
208
|
+
# @return [void]
|
|
209
|
+
def unarchive!
|
|
210
|
+
update!(archived_at: nil)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Find one of this persona's own message attachments by its Active Storage attachment id.
|
|
214
|
+
#
|
|
215
|
+
# Scopes the lookup to attachments whose record is one of *this* persona's messages. The file tools
|
|
216
|
+
# now resolve by *blob* id (see +Protege::StoredFile.find+), so this persona-scoped lookup is
|
|
217
|
+
# currently unused by them — it is retained to become the scoping filter for the deferred
|
|
218
|
+
# authorization pass (see the blob-currency spec §9). Returns +nil+ when the id is unknown or
|
|
219
|
+
# belongs to a different persona.
|
|
220
|
+
#
|
|
221
|
+
# @param id [Integer, String] the +ActiveStorage::Attachment+ id
|
|
222
|
+
# @return [ActiveStorage::Attachment, nil] the scoped attachment, or nil
|
|
223
|
+
def find_attachment(id:)
|
|
224
|
+
ActiveStorage::Attachment
|
|
225
|
+
.where(name: 'attachments', record_type: 'Protege::Message', record_id: messages.select(:id))
|
|
226
|
+
.find_by(id:)
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|