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.
Files changed (255) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +24 -0
  3. data/Rakefile +12 -0
  4. data/app/assets/builds/protege.css +1128 -0
  5. data/app/assets/javascripts/protege/attachment_picker.js +163 -0
  6. data/app/assets/javascripts/protege/copy.js +80 -0
  7. data/app/assets/javascripts/protege/forms.js +28 -0
  8. data/app/assets/javascripts/protege/keys.js +212 -0
  9. data/app/assets/javascripts/protege/resize.js +149 -0
  10. data/app/assets/javascripts/protege/theme.js +25 -0
  11. data/app/assets/stylesheets/protege/application.css +15 -0
  12. data/app/assets/tailwind/application.css +137 -0
  13. data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
  14. data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
  15. data/app/controllers/protege/access_rules_controller.rb +72 -0
  16. data/app/controllers/protege/application_controller.rb +18 -0
  17. data/app/controllers/protege/archives_controller.rb +52 -0
  18. data/app/controllers/protege/email_domains_controller.rb +72 -0
  19. data/app/controllers/protege/home_controller.rb +15 -0
  20. data/app/controllers/protege/messages_controller.rb +46 -0
  21. data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
  22. data/app/controllers/protege/personas_controller.rb +118 -0
  23. data/app/controllers/protege/replies_controller.rb +64 -0
  24. data/app/controllers/protege/responsibilities_controller.rb +82 -0
  25. data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
  26. data/app/controllers/protege/threads_controller.rb +75 -0
  27. data/app/controllers/protege/traces/searches_controller.rb +19 -0
  28. data/app/controllers/protege/traces_controller.rb +52 -0
  29. data/app/helpers/protege/application_helper.rb +30 -0
  30. data/app/helpers/protege/components/badges_helper.rb +59 -0
  31. data/app/helpers/protege/components/buttons_helper.rb +141 -0
  32. data/app/helpers/protege/components/cards_helper.rb +48 -0
  33. data/app/helpers/protege/components/components_helper.rb +30 -0
  34. data/app/helpers/protege/components/dialogs_helper.rb +36 -0
  35. data/app/helpers/protege/components/forms_helper.rb +223 -0
  36. data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
  37. data/app/helpers/protege/components/icons_helper.rb +41 -0
  38. data/app/helpers/protege/components/introspection_helper.rb +162 -0
  39. data/app/helpers/protege/components/layout_helper.rb +169 -0
  40. data/app/helpers/protege/components/menus_helper.rb +68 -0
  41. data/app/helpers/protege/components/pagination_helper.rb +100 -0
  42. data/app/helpers/protege/components/sidebar_helper.rb +211 -0
  43. data/app/helpers/protege/components/tables_helper.rb +59 -0
  44. data/app/helpers/protege/components/typography_helper.rb +64 -0
  45. data/app/helpers/protege/email_domains_helper.rb +22 -0
  46. data/app/helpers/protege/messages_helper.rb +152 -0
  47. data/app/helpers/protege/personas_helper.rb +90 -0
  48. data/app/helpers/protege/responsibilities_helper.rb +63 -0
  49. data/app/helpers/protege/threads_helper.rb +118 -0
  50. data/app/helpers/protege/trace_helper.rb +22 -0
  51. data/app/hooks/protege/event_logger_hook.rb +109 -0
  52. data/app/jobs/protege/application_job.rb +41 -0
  53. data/app/jobs/protege/console_inference_job.rb +57 -0
  54. data/app/jobs/protege/inference_job.rb +60 -0
  55. data/app/jobs/protege/mail_provisioning_job.rb +21 -0
  56. data/app/jobs/protege/responsibility_job.rb +70 -0
  57. data/app/jobs/protege/responsibility_tick_job.rb +19 -0
  58. data/app/mailboxes/protege/agent_mailbox.rb +123 -0
  59. data/app/mailers/protege/application_mailer.rb +65 -0
  60. data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
  61. data/app/models/concerns/protege/broadcastable_message.rb +47 -0
  62. data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
  63. data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
  64. data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
  65. data/app/models/concerns/protege/tool_scoped.rb +88 -0
  66. data/app/models/protege/access_rule.rb +50 -0
  67. data/app/models/protege/application_record.rb +18 -0
  68. data/app/models/protege/current.rb +23 -0
  69. data/app/models/protege/email_domain.rb +136 -0
  70. data/app/models/protege/email_thread.rb +81 -0
  71. data/app/models/protege/message.rb +401 -0
  72. data/app/models/protege/persona.rb +229 -0
  73. data/app/models/protege/responsibility.rb +98 -0
  74. data/app/models/protege/responsibility_run.rb +68 -0
  75. data/app/models/protege/tool_use.rb +115 -0
  76. data/app/models/protege/trace.rb +71 -0
  77. data/app/providers/protege/open_router_provider.rb +317 -0
  78. data/app/resolvers/README.md +255 -0
  79. data/app/resolvers/protege/load_file_resolver.rb +86 -0
  80. data/app/resolvers/protege/load_record_resolver.rb +80 -0
  81. data/app/resolvers/protege/load_text_resolver.rb +40 -0
  82. data/app/resolvers/protege/thread_history_resolver.rb +116 -0
  83. data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
  84. data/app/services/protege/message_search.rb +114 -0
  85. data/app/services/protege/trace_search.rb +102 -0
  86. data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
  87. data/app/tools/protege/create_file_tool.rb +109 -0
  88. data/app/tools/protege/read_attachment_tool.rb +101 -0
  89. data/app/tools/protege/search_emails_tool.rb +129 -0
  90. data/app/tools/protege/send_email_tool.rb +240 -0
  91. data/app/tools/protege/web_fetch_tool.rb +224 -0
  92. data/app/tools/protege/web_search_tool.rb +131 -0
  93. data/app/views/layouts/protege/application.html.slim +19 -0
  94. data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
  95. data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
  96. data/app/views/protege/email_domains/_form.html.slim +10 -0
  97. data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
  98. data/app/views/protege/email_domains/index.html.slim +6 -0
  99. data/app/views/protege/email_domains/new.html.slim +7 -0
  100. data/app/views/protege/email_domains/show.html.slim +36 -0
  101. data/app/views/protege/home/show.html.slim +26 -0
  102. data/app/views/protege/messages/_message.html.slim +19 -0
  103. data/app/views/protege/messages/_sidebar.html.slim +13 -0
  104. data/app/views/protege/messages/index.html.slim +20 -0
  105. data/app/views/protege/personas/_access_rule.html.slim +7 -0
  106. data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
  107. data/app/views/protege/personas/_access_rules.html.slim +19 -0
  108. data/app/views/protege/personas/_actions.html.slim +15 -0
  109. data/app/views/protege/personas/_form.html.slim +29 -0
  110. data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
  111. data/app/views/protege/personas/_sidebar.html.slim +11 -0
  112. data/app/views/protege/personas/edit.html.slim +7 -0
  113. data/app/views/protege/personas/index.html.slim +6 -0
  114. data/app/views/protege/personas/new.html.slim +7 -0
  115. data/app/views/protege/personas/show.html.slim +37 -0
  116. data/app/views/protege/responsibilities/_form.html.slim +21 -0
  117. data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
  118. data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
  119. data/app/views/protege/responsibilities/edit.html.slim +7 -0
  120. data/app/views/protege/responsibilities/index.html.slim +6 -0
  121. data/app/views/protege/responsibilities/new.html.slim +7 -0
  122. data/app/views/protege/responsibilities/show.html.slim +22 -0
  123. data/app/views/protege/responsibility_runs/show.html.slim +13 -0
  124. data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
  125. data/app/views/protege/shared/_header.html.slim +14 -0
  126. data/app/views/protege/shared/_hotkeys.html.slim +43 -0
  127. data/app/views/protege/threads/_composer_footer.html.slim +15 -0
  128. data/app/views/protege/threads/_introspection.html.slim +7 -0
  129. data/app/views/protege/threads/_reasoning.html.slim +4 -0
  130. data/app/views/protege/threads/_reply_form.html.slim +7 -0
  131. data/app/views/protege/threads/_run_marker.html.slim +3 -0
  132. data/app/views/protege/threads/_sidebar.html.slim +15 -0
  133. data/app/views/protege/threads/_thread.html.slim +6 -0
  134. data/app/views/protege/threads/_tool_call.html.slim +8 -0
  135. data/app/views/protege/threads/index.html.slim +6 -0
  136. data/app/views/protege/threads/new.html.slim +15 -0
  137. data/app/views/protege/threads/show.html.slim +25 -0
  138. data/app/views/protege/traces/_sidebar.html.slim +15 -0
  139. data/app/views/protege/traces/index.html.slim +11 -0
  140. data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
  141. data/app/views/protege/traces/searches/show.html.slim +22 -0
  142. data/app/views/protege/traces/show.html.slim +28 -0
  143. data/config/routes.rb +33 -0
  144. data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
  145. data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
  146. data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
  147. data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
  148. data/lib/generators/protege/extension_naming.rb +38 -0
  149. data/lib/generators/protege/hook/hook_generator.rb +33 -0
  150. data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
  151. data/lib/generators/protege/install/install_generator.rb +170 -0
  152. data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
  153. data/lib/generators/protege/persona/persona_generator.rb +35 -0
  154. data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
  155. data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
  156. data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
  157. data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
  158. data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
  159. data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
  160. data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
  161. data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
  162. data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
  163. data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
  164. data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
  165. data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
  166. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
  167. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
  168. data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
  169. data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
  170. data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
  171. data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
  172. data/lib/generators/protege/provider/provider_generator.rb +44 -0
  173. data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
  174. data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
  175. data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
  176. data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
  177. data/lib/generators/protege/tool/tool_generator.rb +34 -0
  178. data/lib/protege/configuration.rb +171 -0
  179. data/lib/protege/engine.rb +103 -0
  180. data/lib/protege/errors/bad_request_error.rb +8 -0
  181. data/lib/protege/errors/contract_violation_error.rb +8 -0
  182. data/lib/protege/errors/error.rb +30 -0
  183. data/lib/protege/errors/generic_error.rb +7 -0
  184. data/lib/protege/errors/internal_error.rb +8 -0
  185. data/lib/protege/errors/invalid_model_error.rb +8 -0
  186. data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
  187. data/lib/protege/errors/rate_limited_error.rb +8 -0
  188. data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
  189. data/lib/protege/errors/response_parse_failed_error.rb +8 -0
  190. data/lib/protege/errors/timeout_error.rb +8 -0
  191. data/lib/protege/errors/tool_not_available_error.rb +12 -0
  192. data/lib/protege/errors/tool_not_found_error.rb +9 -0
  193. data/lib/protege/errors/unauthorized_error.rb +8 -0
  194. data/lib/protege/errors/unavailable_error.rb +8 -0
  195. data/lib/protege/errors/unsupported_capability_error.rb +8 -0
  196. data/lib/protege/events/event.rb +98 -0
  197. data/lib/protege/events/inference_chunk_event.rb +17 -0
  198. data/lib/protege/events/inference_completed_event.rb +12 -0
  199. data/lib/protege/events/inference_failed_event.rb +12 -0
  200. data/lib/protege/events/inference_generated_event.rb +19 -0
  201. data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
  202. data/lib/protege/events/inference_started_event.rb +11 -0
  203. data/lib/protege/events/loop_run_completed_event.rb +11 -0
  204. data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
  205. data/lib/protege/events/loop_run_failed_event.rb +12 -0
  206. data/lib/protege/events/loop_run_started_event.rb +11 -0
  207. data/lib/protege/events/tool_call_completed_event.rb +12 -0
  208. data/lib/protege/events/tool_call_failed_event.rb +12 -0
  209. data/lib/protege/events/tool_call_started_event.rb +11 -0
  210. data/lib/protege/events/tool_calls_received_event.rb +11 -0
  211. data/lib/protege/extensions/hook.rb +13 -0
  212. data/lib/protege/extensions/hook_mixin.rb +90 -0
  213. data/lib/protege/extensions/provider.rb +12 -0
  214. data/lib/protege/extensions/provider_mixin.rb +124 -0
  215. data/lib/protege/extensions/resolver.rb +12 -0
  216. data/lib/protege/extensions/resolver_mixin.rb +58 -0
  217. data/lib/protege/extensions/tool.rb +12 -0
  218. data/lib/protege/extensions/tool_mixin.rb +217 -0
  219. data/lib/protege/gateway/access_control.rb +66 -0
  220. data/lib/protege/gateway/access_policy.rb +94 -0
  221. data/lib/protege/gateway/attachment_policy.rb +115 -0
  222. data/lib/protege/gateway/mail/address.rb +145 -0
  223. data/lib/protege/gateway/mail/attachment.rb +17 -0
  224. data/lib/protege/gateway/mail/message_id.rb +104 -0
  225. data/lib/protege/gateway/mail/outbound.rb +181 -0
  226. data/lib/protege/gateway/mail/references.rb +107 -0
  227. data/lib/protege/gateway.rb +398 -0
  228. data/lib/protege/inference/provider/request.rb +110 -0
  229. data/lib/protege/inference/provider/response.rb +50 -0
  230. data/lib/protege/inference/provider/tool_call.rb +52 -0
  231. data/lib/protege/inference.rb +121 -0
  232. data/lib/protege/loop/schedule.rb +66 -0
  233. data/lib/protege/loop/scheduler.rb +42 -0
  234. data/lib/protege/loop.rb +37 -0
  235. data/lib/protege/orchestrator/context.rb +65 -0
  236. data/lib/protege/orchestrator/harness.rb +305 -0
  237. data/lib/protege/orchestrator/reply_context.rb +21 -0
  238. data/lib/protege/orchestrator/reply_harness.rb +68 -0
  239. data/lib/protege/orchestrator/resolver_chain.rb +93 -0
  240. data/lib/protege/orchestrator/responsibility_context.rb +22 -0
  241. data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
  242. data/lib/protege/orchestrator.rb +37 -0
  243. data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
  244. data/lib/protege/subscribers/tracing.rb +65 -0
  245. data/lib/protege/types/document_part.rb +13 -0
  246. data/lib/protege/types/image_part.rb +11 -0
  247. data/lib/protege/types/model_message.rb +40 -0
  248. data/lib/protege/types/result.rb +113 -0
  249. data/lib/protege/types/stored_file.rb +127 -0
  250. data/lib/protege/types/text_part.rb +8 -0
  251. data/lib/protege/types/tool_result.rb +16 -0
  252. data/lib/protege/version.rb +6 -0
  253. data/lib/protege.rb +47 -0
  254. data/lib/tasks/protege_tasks.rake +16 -0
  255. 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