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,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # View helpers for rendering a +Message+ within a thread — sender, timestamp,
5
+ # recipients, subject, and expandable raw headers. Liveness (processing/reasoning/tool use) is not
6
+ # shown on the message; it lives in the console's introspection panel.
7
+ module MessagesHelper
8
+ # The search-result row title: the email subject, or a placeholder when blank.
9
+ #
10
+ # @param message [Protege::Message] the matched message
11
+ # @return [String] the subject or "(no subject)"
12
+ def search_result_title(message)
13
+ message.subject.presence || '(no subject)'
14
+ end
15
+
16
+ # The search-result row's right-aligned date — the email's sent time in short +m/d/yy+ form.
17
+ #
18
+ # @param message [Protege::Message] the matched message
19
+ # @return [String, nil] the short date, or nil when unsent
20
+ def search_result_date(message)
21
+ message.sent_at&.strftime('%-m/%-d/%y')
22
+ end
23
+
24
+ # Render the sender line — persona name for outbound mail, local part of the
25
+ # address for inbound mail — followed by the full address.
26
+ #
27
+ # @param message [Protege::Message] the message to render the sender for
28
+ # @return [ActiveSupport::SafeBuffer] the bold +"name <address>"+ span
29
+ def message_sender(message)
30
+ name = if message.outbound?
31
+ message.persona.name
32
+ else
33
+ message.from_address.to_s.split('@').first
34
+ end
35
+
36
+ tag.span("#{name} <#{message.from_address}>", class: 'font-semibold')
37
+ end
38
+
39
+ # Render the message's send time as a +<time>+ element.
40
+ #
41
+ # @param message [Protege::Message] the message whose timestamp to render
42
+ # @return [ActiveSupport::SafeBuffer] a formatted +<time>+ tag
43
+ def message_timestamp(message)
44
+ tag.time(
45
+ message.sent_at.strftime('%b %-d, %Y %H:%M'),
46
+ datetime: message.sent_at.iso8601,
47
+ class: 'flex-shrink-0 text-xs',
48
+ style: 'color: var(--text-faint)'
49
+ )
50
+ end
51
+
52
+ # Render the To line, appending a Cc line only when Cc recipients exist.
53
+ #
54
+ # @param message [Protege::Message] the message whose recipients to render
55
+ # @return [ActiveSupport::SafeBuffer] the recipients line
56
+ def message_recipients(message)
57
+ to = tag.span("To: #{Array(message.to_addresses).join(', ')}")
58
+ cc = if Array(message.cc_addresses).any?
59
+ tag.span("Cc: #{message.cc_addresses.join(', ')}", class: 'ml-2')
60
+ else
61
+ ''.html_safe
62
+ end
63
+
64
+ tag.div(class: 'mt-1 text-xs', style: 'color: var(--text-muted)') { safe_join([to, cc]) }
65
+ end
66
+
67
+ # Render the subject line, but only when it differs from the thread subject.
68
+ #
69
+ # @param message [Protege::Message] the message whose subject to consider
70
+ # @return [ActiveSupport::SafeBuffer] the subject line, or an empty buffer
71
+ def message_subject(message)
72
+ if message.subject.present? && message.subject != message.email_thread.subject
73
+ tag.div("Subject: #{message.subject}", class: 'mt-1 text-xs font-medium')
74
+ else
75
+ ''.html_safe
76
+ end
77
+ end
78
+
79
+ # Render an expandable +<details>+ block listing the message's raw headers.
80
+ #
81
+ # @param message [Protege::Message] the message whose headers to render
82
+ # @return [ActiveSupport::SafeBuffer] the collapsible headers block
83
+ def message_headers(message)
84
+ tag.details(class: 'mt-1') do
85
+ tag.summary('Headers', class: 'cursor-pointer text-xs', style: 'color: var(--text-faint)') +
86
+ message_headers_list(message)
87
+ end
88
+ end
89
+
90
+ # Render a message's attachments as a row of download-link chips, or nothing when there are none.
91
+ #
92
+ # Each chip links to the blob via Active Storage's host-app route (+main_app.rails_blob_path+, since
93
+ # those routes live on the host, not the engine) and shows the filename and a human-readable size.
94
+ # +link_to+ escapes the filename, which is copied verbatim from attacker-controlled inbound headers.
95
+ #
96
+ # The link forces a download two ways: +disposition: "attachment"+ makes the service set
97
+ # +Content-Disposition: attachment+ (so even an +.html+ blob downloads rather than rendering), and
98
+ # the +download+ attribute both hints the filename and opts the anchor out of Turbo Drive — without
99
+ # it Turbo intercepts the click and renders an HTML attachment as a page instead of downloading it.
100
+ #
101
+ # @param message [Protege::Message] the message whose attachments to render
102
+ # @return [ActiveSupport::SafeBuffer] the chips row, or an empty buffer
103
+ def message_attachments(message)
104
+ return ''.html_safe unless message.attachments.attached?
105
+
106
+ chips = message.attachments.map do |attachment|
107
+ label = "#{attachment.filename} (#{number_to_human_size(attachment.byte_size)})"
108
+ link_to(label,
109
+ main_app.rails_blob_path(attachment, disposition: 'attachment', only_path: true),
110
+ download: attachment.filename.to_s,
111
+ class: 'inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium hover:underline',
112
+ style: 'background: var(--surface-subtle); border: 1px solid var(--border)')
113
+ end
114
+
115
+ tag.div(class: 'mt-3 flex flex-wrap gap-2') { safe_join(chips) }
116
+ end
117
+
118
+ private
119
+
120
+ # Build the ordered label/value header pairs shown in the headers block.
121
+ #
122
+ # Conditional headers are included only when their value is present.
123
+ #
124
+ # @param message [Protege::Message] the message to read header values from
125
+ # @return [Array<Array(String, String)>] +[label, value]+ pairs in display order
126
+ def message_header_pairs(message)
127
+ pairs = [['Message-ID', message.message_id]]
128
+
129
+ pairs << ['In-Reply-To', message.in_reply_to] if message.in_reply_to.present?
130
+ pairs << ['References', message.references_header] if message.references_header.present?
131
+ pairs << ['Direction', message.direction]
132
+ pairs << ['Correlation-ID', message.correlation_id] if message.correlation_id.present?
133
+
134
+ pairs
135
+ end
136
+
137
+ # Render the +<dl>+ body of the headers block from the message's header pairs.
138
+ #
139
+ # @param message [Protege::Message] the message whose headers to list
140
+ # @return [ActiveSupport::SafeBuffer] the definition list of headers
141
+ def message_headers_list(message)
142
+ tag.dl(class: 'mt-1 space-y-0.5 text-xs', style: 'color: var(--text-muted)') do
143
+ safe_join(message_header_pairs(message).map do |label, value|
144
+ tag.div(class: 'flex gap-1') do
145
+ tag.dt("#{label}:", class: 'font-medium') +
146
+ tag.dd(value, class: 'break-all')
147
+ end
148
+ end)
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # View helpers for persona pages — selectors, labels, status, and resolver display.
5
+ module PersonasHelper
6
+ # List the loaded +Persona+ subclasses for the new-persona type selector.
7
+ #
8
+ # @return [Array<Class>] the +Persona+ descendant classes
9
+ def available_persona_types
10
+ Persona.descendants
11
+ end
12
+
13
+ # List active email domains for the persona form's domain selector.
14
+ #
15
+ # @return [ActiveRecord::Relation<Protege::EmailDomain>] domains eligible for selection
16
+ def available_email_domains
17
+ EmailDomain.all
18
+ end
19
+
20
+ # Fetch every +Persona+ ordered by name for sidebar listing.
21
+ #
22
+ # @return [ActiveRecord::Relation<Protege::Persona>] personas ordered alphabetically
23
+ def all_personas
24
+ Persona.order(:name)
25
+ end
26
+
27
+ # Format a persona's name together with its type for display.
28
+ #
29
+ # persona_label(persona) # => "Bob <Agent>"
30
+ #
31
+ # @param persona [Protege::Persona] the persona to label
32
+ # @return [String] the +"name <Type>"+ label
33
+ def persona_label(persona)
34
+ "#{persona.name} <#{persona.class.display_name}>"
35
+ end
36
+
37
+ # Render a status dot reflecting whether a persona is available.
38
+ #
39
+ # @param persona [Protege::Persona] the persona whose availability to show
40
+ # @return [ActiveSupport::SafeBuffer] a green dot when active, red otherwise
41
+ def persona_status(persona)
42
+ if persona.active?
43
+ status_dot('Available', :green)
44
+ else
45
+ status_dot('Unavailable', :red)
46
+ end
47
+ end
48
+
49
+ # Format a persona's resolver chain as a hash suitable for +info_card+ (rendered full-width, since
50
+ # the resolver class names are long). Each entry maps the resolver's short class name to a summary
51
+ # of its positional args, keyword args, and whether it was given a block.
52
+ #
53
+ # @param persona [Protege::Persona] the persona whose reply (+message_resolvers+) chain to format
54
+ # @return [Hash{String => String}] resolver-name => configuration summary
55
+ def resolver_pairs(persona)
56
+ pairs = persona.message_resolvers.entries.each_with_object({}) do |(klass, args, kwargs, block), accumulator|
57
+ accumulator[klass.name.demodulize] = resolver_config(args, kwargs, block)
58
+ end
59
+
60
+ pairs.any? ? pairs : { 'Resolvers' => 'None configured' }
61
+ end
62
+
63
+ # The tool classes a persona's operator may toggle — the checkbox universe on the persona form.
64
+ #
65
+ # This is the persona's *grant* (its code-declared ceiling), or every registered tool when no grant
66
+ # was declared. It is deliberately the grant rather than the effective set: the operator toggles
67
+ # within what the code allows, so a tool disabled in the DB still appears (unchecked) and can be
68
+ # switched back on, while a tool outside the grant is never offered.
69
+ #
70
+ # @param persona [Protege::Persona] the persona being edited
71
+ # @return [Array<Class>] the toggleable tool classes
72
+ def persona_tool_options(persona)
73
+ grant = persona.class.tool_grant
74
+
75
+ grant ? ToolMixin.registered.select { grant.include?(_1.id) } : ToolMixin.registered
76
+ end
77
+
78
+ private
79
+
80
+ # Summarize a resolver entry's positional args, keyword args, and whether it was given a block.
81
+ #
82
+ # @return [String] a comma-joined summary, or "" when the resolver takes no configuration
83
+ def resolver_config(args, kwargs, block)
84
+ parts = args.map(&:inspect)
85
+ parts += kwargs.map { |key, value| "#{key}: #{value.inspect}" }
86
+ parts << '{ … }' if block
87
+ parts.join(', ')
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # View helpers for responsibility pages — status display and run-history formatting.
5
+ module ResponsibilitiesHelper
6
+ # Fetch every +Responsibility+ (persona eager-loaded) for the global sidebar listing. The model's
7
+ # default scope already orders by name.
8
+ #
9
+ # @return [ActiveRecord::Relation<Protege::Responsibility>] all responsibilities
10
+ def all_responsibilities
11
+ Responsibility.includes(:persona)
12
+ end
13
+
14
+ # Render a status dot reflecting whether a responsibility is active.
15
+ #
16
+ # @param responsibility [Protege::Responsibility] the responsibility whose state to show
17
+ # @return [ActiveSupport::SafeBuffer] a green dot when active, grey when paused
18
+ def responsibility_status(responsibility)
19
+ if responsibility.active?
20
+ status_dot('Active', :green)
21
+ else
22
+ status_dot('Paused', :gray)
23
+ end
24
+ end
25
+
26
+ # Render a run's status as a colour-coded badge.
27
+ #
28
+ # @param run [Protege::ResponsibilityRun] the run whose status to badge
29
+ # @return [ActiveSupport::SafeBuffer] the badge markup
30
+ def run_status_badge(run)
31
+ colors = { 'completed' => :green, 'failed' => :red, 'running' => :gray, 'pending' => :gray }
32
+ badge(run.status, colors.fetch(run.status, :gray))
33
+ end
34
+
35
+ # Build the responsibility Show page's recent-runs table — its headers and one
36
+ # +[status badge, when, detail link]+ row per run, newest first (capped at ten).
37
+ #
38
+ # @param responsibility [Protege::Responsibility] the responsibility whose runs to format
39
+ # @return [Protege::Components::TablesHelper::Table] the headers + rows for +table+
40
+ def responsibility_runs_table(responsibility)
41
+ rows = responsibility.responsibility_runs.order(created_at: :desc).limit(10).map do |run|
42
+ [
43
+ run_status_badge(run),
44
+ run.correlation_id,
45
+ run_time(run),
46
+ ui_link('Details', responsibility_run_path(run))
47
+ ]
48
+ end
49
+
50
+ Components::TablesHelper::Table.new(headers: ['Status', 'Correlation ID', 'When', ''], rows:)
51
+ end
52
+
53
+ private
54
+
55
+ # The most relevant timestamp for a run: when it started, falling back to when it was enqueued.
56
+ #
57
+ # @param run [Protege::ResponsibilityRun]
58
+ # @return [String] a formatted timestamp
59
+ def run_time(run)
60
+ (run.started_at || run.created_at).strftime('%Y-%m-%d %H:%M')
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # View helpers for thread pages — the inbox list, compose selectors, sidebar
5
+ # display, and the thread detail header.
6
+ module ThreadsHelper
7
+ # Fetch every +EmailThread+ ordered by most recent activity.
8
+ #
9
+ # @return [ActiveRecord::Relation<Protege::EmailThread>] threads newest-activity first
10
+ def inbox_threads
11
+ EmailThread.order(last_message_at: :desc)
12
+ end
13
+
14
+ # List the active personas eligible for the compose form.
15
+ #
16
+ # @return [ActiveRecord::Relation<Protege::Persona>] personas eligible for selection
17
+ def available_personas
18
+ Persona.active
19
+ end
20
+
21
+ # Format the available personas as +[label, id]+ pairs for a select dropdown.
22
+ #
23
+ # @return [Array<Array(String, Integer)>] option label/value pairs
24
+ def available_persona_options
25
+ available_personas.map { |persona| ["#{persona.name} <#{persona.email_address}>", persona.id] }
26
+ end
27
+
28
+ # Format a thread subject with its message count for sidebar display.
29
+ # Truncation is handled by the +sidebar_item+ helper.
30
+ #
31
+ # thread_title(thread) # => "Hello (3)"
32
+ #
33
+ # @param thread [Protege::EmailThread] the thread to title
34
+ # @return [String] the subject, suffixed with the count when it exceeds one
35
+ def thread_title(thread)
36
+ subject = thread.subject.presence || '(no subject)'
37
+ thread.message_count > 1 ? "#{thread.persona.name} - #{subject} (#{thread.message_count})" : subject
38
+ end
39
+
40
+ # Format the thread's last-activity date for sidebar display.
41
+ #
42
+ # @param thread [Protege::EmailThread] the thread to date
43
+ # @return [String, nil] the short +m/d/yy+ date, or nil when no activity
44
+ def thread_date(thread)
45
+ thread.last_message_at&.strftime('%-m/%-d/%y')
46
+ end
47
+
48
+ # Build a one-line preview from the first line of the latest message.
49
+ #
50
+ # @param thread [Protege::EmailThread] the thread to preview
51
+ # @return [String] the trimmed first line, or an em dash when empty
52
+ def thread_preview(thread)
53
+ thread.latest_message&.text_body.to_s.lines.first.to_s.strip.presence || '—'
54
+ end
55
+
56
+ # Render the thread detail header — subject, persona, message count, last
57
+ # activity, and thread ID. An optional block renders actions beside the title.
58
+ #
59
+ # @param thread [Protege::EmailThread] the thread whose header to render
60
+ # @yield optional content rendered to the right of the subject
61
+ # @return [ActiveSupport::SafeBuffer] the header markup
62
+ def thread_header(thread, &block)
63
+ tag.div(class: 'flex-shrink-0 px-5 py-3') do
64
+ thread_header_title(thread, &block) +
65
+ thread_header_meta(thread) +
66
+ thread_header_id(thread)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ # Render the title row — the subject and any caller-supplied actions block.
73
+ #
74
+ # @param thread [Protege::EmailThread] the thread whose subject to render
75
+ # @yield optional content rendered to the right of the subject
76
+ # @return [ActiveSupport::SafeBuffer] the title row markup
77
+ def thread_header_title(thread, &block)
78
+ tag.div(class: 'flex items-center justify-between') do
79
+ heading_3(thread.subject.presence || '(no subject)') +
80
+ (block ? capture(&block) : ''.html_safe)
81
+ end
82
+ end
83
+
84
+ # Render the metadata row — persona, message count, and last activity.
85
+ #
86
+ # @param thread [Protege::EmailThread] the thread whose metadata to render
87
+ # @return [ActiveSupport::SafeBuffer] the metadata row markup
88
+ def thread_header_meta(thread)
89
+ tag.div(class: 'mt-1 flex items-center gap-3 text-xs', style: 'color: var(--text-muted)') do
90
+ tag.span do
91
+ 'Persona: '.html_safe + tag.span(thread.persona.name, class: 'font-medium')
92
+ end +
93
+ tag.span(pluralize(thread.message_count, 'message')) +
94
+ thread_header_last_activity(thread)
95
+ end
96
+ end
97
+
98
+ # Render the last-activity span, or an empty buffer when there is none.
99
+ #
100
+ # @param thread [Protege::EmailThread] the thread whose last activity to render
101
+ # @return [ActiveSupport::SafeBuffer] the last-activity span, or an empty buffer
102
+ def thread_header_last_activity(thread)
103
+ return ''.html_safe unless thread.last_message_at
104
+
105
+ tag.span("Last activity: #{thread.last_message_at.strftime('%b %-d, %Y %H:%M')}")
106
+ end
107
+
108
+ # Render the thread ID row.
109
+ #
110
+ # @param thread [Protege::EmailThread] the thread whose ID to render
111
+ # @return [ActiveSupport::SafeBuffer] the thread ID row markup
112
+ def thread_header_id(thread)
113
+ tag.div(class: 'mt-1 text-xs', style: 'color: var(--text-faint)') do
114
+ "Thread ID: #{thread.thread_id}"
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # View helpers for the tracing dashboard — the one place the trace views ask about tracing state, so
5
+ # the +config.tracing+ hash shape stays out of the templates.
6
+ module TraceHelper
7
+ # Fetch captured turns newest first — the sidebar list. A helper (like +inbox_threads+) so the +show+
8
+ # page's sidebar sources its own list without the controller re-deriving it into an ivar.
9
+ #
10
+ # @return [ActiveRecord::Relation<Protege::Trace>] traces newest first
11
+ def recent_traces
12
+ Trace.recent
13
+ end
14
+
15
+ # Whether inference tracing is currently capturing turns — the +config.tracing+ master switch.
16
+ #
17
+ # @return [Boolean] true when tracing is enabled
18
+ def tracing_enabled?
19
+ Protege.configuration.tracing[:enabled] ? true : false
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Built-in event logger hook. Subscribes to the full lifecycle of Protege events and writes
5
+ # human-readable log lines to the configured +Protege.configuration.logger+, giving operators a
6
+ # trace of each agent run.
7
+ #
8
+ # Subclassing +Protege::Hook+ means the engine wires these handlers onto the event bus once
9
+ # at boot; each +on+ block receives a typed +Protege::Event+ and runs isolated, so a logging
10
+ # error can never break inference. As a built-in it is always active.
11
+ class EventLoggerHook < Protege::Hook
12
+ # Log the start of an inference run for a persona.
13
+ on InferenceStartedEvent do |event|
14
+ log "[#{ts}] #{cid(event)} INF START persona=#{event.persona&.name}"
15
+ end
16
+
17
+ # Log the completion of an inference run for a persona.
18
+ on InferenceCompletedEvent do |event|
19
+ log "[#{ts}] #{cid(event)} INF DONE persona=#{event.persona&.name}"
20
+ end
21
+
22
+ # Log the set of tool calls the model requested in a single turn.
23
+ on ToolCallsReceivedEvent do |event|
24
+ names = event.tool_calls&.map(&:name)&.join(', ')
25
+ log "[#{ts}] #{cid(event)} TOOLS RECV [#{names}]"
26
+ end
27
+
28
+ # Log the start of a single tool invocation.
29
+ on ToolCallStartedEvent do |event|
30
+ tc = event.tool_call
31
+ log "[#{ts}] #{cid(event)} TOOL START #{tc&.name} id=#{tc&.id}"
32
+ end
33
+
34
+ # Log a successful tool invocation and its result data.
35
+ on ToolCallCompletedEvent do |event|
36
+ tc = event.tool_call
37
+ log "[#{ts}] #{cid(event)} TOOL OK #{tc&.name} id=#{tc&.id} data=#{event.result&.data}"
38
+ end
39
+
40
+ # Log a failed tool invocation with its error class, message, and a short backtrace.
41
+ on ToolCallFailedEvent do |event|
42
+ tc = event.tool_call
43
+ log "[#{ts}] #{cid(event)} TOOL FAIL #{tc&.name} id=#{tc&.id} " \
44
+ "#{event.error&.class}: #{event.error&.message}\n #{error_backtrace(event.error)}"
45
+ end
46
+
47
+ # Log the agent loop hitting its turn cap with tool calls still pending.
48
+ on InferenceMaxTurnsReachedEvent do |event|
49
+ names = event.tool_calls&.map(&:name)&.join(', ')
50
+ log "[#{ts}] #{cid(event)} MAX TURNS turn=#{event.turn} pending=[#{names}]"
51
+ end
52
+
53
+ # Log a scheduled responsibility run being enqueued — pending, awaiting a worker (the Loop layer).
54
+ on LoopRunEnqueuedEvent do |event|
55
+ log "[#{ts}] #{cid(event)} LOOP PENDING #{event.name} persona=#{event.persona_name}"
56
+ end
57
+
58
+ # Log the start of a scheduled responsibility run (the Loop layer).
59
+ on LoopRunStartedEvent do |event|
60
+ log "[#{ts}] #{cid(event)} LOOP START #{event.name} persona=#{event.persona_name}"
61
+ end
62
+
63
+ # Log the successful completion of a scheduled responsibility run.
64
+ on LoopRunCompletedEvent do |event|
65
+ log "[#{ts}] #{cid(event)} LOOP DONE #{event.name} persona=#{event.persona_name}"
66
+ end
67
+
68
+ # Log a failed scheduled responsibility run with its error class, message, and short backtrace.
69
+ on LoopRunFailedEvent do |event|
70
+ log "[#{ts}] #{cid(event)} LOOP FAIL #{event.name} persona=#{event.persona_name} " \
71
+ "#{event.error&.class}: #{event.error&.message}\n #{error_backtrace(event.error)}"
72
+ end
73
+
74
+ private
75
+
76
+ # Render the first few backtrace frames of an error for compact logging.
77
+ #
78
+ # @param error [Exception, nil] the captured error, or +nil+
79
+ # @return [String, nil] up to three indented backtrace frames, or +nil+ when unavailable
80
+ def error_backtrace(error)
81
+ return nil unless error
82
+
83
+ error.backtrace&.first(3)&.join("\n ")
84
+ end
85
+
86
+ # The correlation id for log line prefixes, falling back to a placeholder.
87
+ #
88
+ # @param event [Protege::Event] the event being logged
89
+ # @return [String] the correlation id, or +'--------'+ when absent
90
+ def cid(event)
91
+ event.correlation_id || '--------'
92
+ end
93
+
94
+ # Write a single line to the configured Protege logger at info level.
95
+ #
96
+ # @param line [String] the formatted log line
97
+ # @return [void]
98
+ def log(line)
99
+ Protege.configuration.logger.info(line)
100
+ end
101
+
102
+ # Produce a UTC ISO-8601 timestamp for log line prefixes.
103
+ #
104
+ # @return [String] the current time in ISO-8601 UTC
105
+ def ts
106
+ Time.now.utc.iso8601
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Abstract base class for every Active Job in the Protege engine.
5
+ #
6
+ # Both +InferenceJob+ and +ConsoleInferenceJob+ inherit from this class rather than
7
+ # +ActiveJob::Base+ directly, matching the host-app convention of an +ApplicationJob+ base. It
8
+ # provides a single place to configure cross-cutting job behaviour — retry policy, queue adapter
9
+ # selection, and around-perform callbacks — so individual jobs stay focused on their own work.
10
+ #
11
+ # == Failure policy
12
+ #
13
+ # Inference can fail for two very different reasons, and they are handled differently:
14
+ #
15
+ # * +Protege::Error::TRANSIENT+ are provider hiccups that a later attempt may clear (rate
16
+ # limits, timeouts, outages). These are retried with polynomial backoff.
17
+ # * +Protege::Error::PERMANENT+ cannot be fixed by retrying — a malformed request, bad
18
+ # credentials, an unknown model/capability, an unparseable response (plus a vanished record).
19
+ # Retrying only wastes work, so these are discarded after the job has recorded the failure.
20
+ #
21
+ # Any other error falls through to the queue adapter's default handling (no retry), still recorded
22
+ # on the message by each job's own +rescue+.
23
+ class ApplicationJob < ActiveJob::Base
24
+ # Failures where retrying cannot help; discarded after the failure is recorded.
25
+ discard_on(ActiveRecord::RecordNotFound, *Protege::Error::PERMANENT)
26
+ # Provider failures a later attempt may clear.
27
+ retry_on(*Protege::Error::TRANSIENT, attempts: 3, wait: :polynomially_longer)
28
+
29
+ private
30
+
31
+ # Restore the per-run correlation ID so this job's events share the originating trace token. Every
32
+ # Protege job sets this at the top of +perform+, keeping events correlated from ingress (or the
33
+ # scheduler tick) through inference.
34
+ #
35
+ # @param correlation_id [String] the trace token to thread through this job's events
36
+ # @return [void]
37
+ def restore_correlation_id(correlation_id)
38
+ Protege::Current.correlation_id = correlation_id
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Runs a dashboard-originated message through the inference pipeline.
5
+ #
6
+ # The console counterpart to +InferenceJob+: enqueued by +Gateway.accept_console_message+ when a
7
+ # developer sends a message from the HQ console. Because console messages bypass SMTP entirely,
8
+ # this job operates on a +Message+ that has already been persisted, rather than on an
9
+ # +ActionMailbox::InboundEmail+ it must ingest first. The remainder of the flow — correlation-ID
10
+ # restoration, running the Orchestrator's +Harness+, and recording the outcome — mirrors
11
+ # +InferenceJob+.
12
+ #
13
+ # == Lifecycle
14
+ #
15
+ # 1. Restore the correlation ID for event tracing.
16
+ # 2. Find the +Message+ and +Persona+ from their IDs.
17
+ # 3. Transition the message to +processing+.
18
+ # 4. Run the inference loop (+Harness+).
19
+ # 5. Mark the message +processed+ on success, +failed+ on error.
20
+ class ConsoleInferenceJob < ApplicationJob
21
+ queue_as :default
22
+
23
+ # Process one pre-persisted console message end to end.
24
+ #
25
+ # @param message_id [Integer] primary key of the pre-persisted +Message+ record
26
+ # @param persona_id [Integer] primary key of the +Persona+; STI loads the correct subclass
27
+ # @param correlation_id [String] trace token forwarded from the console request
28
+ # @return [void]
29
+ # @raise [StandardError] re-raised after being recorded on the message, to trigger retries
30
+ def perform(message_id:, persona_id:, correlation_id:)
31
+ restore_correlation_id(correlation_id)
32
+
33
+ message = Message.find(message_id)
34
+ persona = Persona.find(persona_id)
35
+
36
+ process(message:, persona:)
37
+ rescue StandardError => e
38
+ message&.mark_failed!(e)
39
+ raise
40
+ end
41
+
42
+ private
43
+
44
+ # Run the message through the inference loop and record success.
45
+ #
46
+ # @param message [Protege::Message] the message to process
47
+ # @param persona [Protege::Persona] the persona handling it
48
+ # @return [void]
49
+ def process(message:, persona:)
50
+ message.start_processing!
51
+
52
+ Orchestrator.reply(persona:, message:)
53
+
54
+ message.mark_processed!
55
+ end
56
+ end
57
+ end