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,7 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar", current: @persona
3
+
4
+ = page_container do
5
+ = page_header("Edit #{@persona.name}")
6
+
7
+ = card { render "form", persona: @persona }
@@ -0,0 +1,6 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar"
3
+
4
+ = page_empty do
5
+ | Select a persona from the sidebar, or  
6
+ = ui_link("create a new one.", new_persona_path)
@@ -0,0 +1,7 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar"
3
+
4
+ = page_container do
5
+ = page_header("New persona", "Specify the email address this persona receives mail at. It must match your MX records.")
6
+
7
+ = card { render "form", persona: @persona }
@@ -0,0 +1,37 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar", current: @persona
3
+
4
+ = page_container do
5
+ = page_header(@persona.name, @persona.class.display_name) do
6
+ = render("actions", persona: @persona)
7
+
8
+ .space-y-4
9
+ = info_card("Email address", { "Address" => @persona.email_address })
10
+
11
+ = card do
12
+ = heading_2("Instructions")
13
+ - if @persona.instructions.present?
14
+ .mt-3
15
+ = body_text(@persona.instructions)
16
+ - else
17
+ .mt-3
18
+ = hint("No instructions set yet — add them via Edit. They're used as the system prompt only when a resolver (e.g. LoadText) includes them in the agent's context.")
19
+
20
+ = card do
21
+ = heading_2("Available Tools")
22
+ - if @persona.available_tool_ids.any?
23
+ .mt-3.flex.flex-wrap.gap-2
24
+ - @persona.available_tool_ids.each do |tool_id|
25
+ = badge(tool_id)
26
+ - else
27
+ .mt-3
28
+ = hint("No tools are available to this persona — every tool in its scope has been disabled.")
29
+
30
+ = render("access_rules", persona: @persona)
31
+
32
+ = render("protege/shared/confirm_dialog",
33
+ id: "delete-persona",
34
+ title: "Delete #{@persona.name}?",
35
+ message: "This permanently removes the persona and ALL of its data — every thread, message, attachment, responsibility, access rule, and tool-use record. This cannot be undone. To keep the history but stop the persona instead, archive it.",
36
+ action_url: persona_path(@persona),
37
+ action_text: "Delete persona")
@@ -0,0 +1,21 @@
1
+ - url = responsibility.new_record? ? responsibilities_path : responsibility_path(responsibility)
2
+ - method = responsibility.new_record? ? :post : :patch
3
+
4
+ = form_with url:, method:, scope: :responsibility, class: "space-y-5" do |f|
5
+ = form_errors(responsibility)
6
+
7
+ = form_select(f, :persona_id, all_personas.map { |p| [p.name, p.id] }, label: "Persona", prompt: "Choose a persona")
8
+
9
+ = form_field(f, :name, placeholder: "e.g. Morning digest")
10
+
11
+ = form_field(f, :schedule, label: "Schedule", placeholder: "0 9 * * 1-5", hint: "A 5-field cron expression (minute hour day-of-month month day-of-week) in the app timezone — e.g. \"0 9 * * 1-5\" is 9am on weekdays, \"*/15 * * * *\" every 15 minutes.")
12
+
13
+ = form_text_area(f, :instructions, label: "Instructions", rows: 8, hint: "The task the agent carries out on each run, delivered as the opening message. The persona's responsibility_resolvers chain supplies the system context around it.")
14
+
15
+ - unless responsibility.new_record?
16
+ = form_checkbox(f, :active, label: "Active")
17
+
18
+ .flex.items-center.gap-3.pt-2
19
+ = button(responsibility.new_record? ? "Create responsibility" : "Save changes", type: :submit)
20
+
21
+ = ui_link("Cancel", responsibility.new_record? ? responsibilities_path : responsibility_path(responsibility))
@@ -0,0 +1,5 @@
1
+ = sidebar_item(responsibility.name, responsibility_path(responsibility),
2
+ detail: responsibility_status(responsibility),
3
+ subtitle: "#{responsibility.persona.name} · #{responsibility.schedule}",
4
+ active: local_assigns[:active],
5
+ id: dom_id(responsibility))
@@ -0,0 +1,11 @@
1
+ - pagy, responsibilities = paginate(all_responsibilities)
2
+
3
+ = sidebar_content do
4
+ = sidebar_header("Responsibilities (#{pagy.count})", new_path: new_responsibility_path)
5
+
6
+ = sidebar_stream("protege:responsibilities", pagy: pagy)
7
+
8
+ = sidebar_body(responsibilities, empty: "No responsibilities yet", id: "responsibilities_list") do |responsibility|
9
+ = render "responsibility_sidebar_item", responsibility: responsibility, active: local_assigns[:current] == responsibility
10
+
11
+ = sidebar_pagination(pagy)
@@ -0,0 +1,7 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar", current: @responsibility
3
+
4
+ = page_container do
5
+ = page_header("Edit #{@responsibility.name}")
6
+
7
+ = card { render "form", responsibility: @responsibility }
@@ -0,0 +1,6 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar"
3
+
4
+ = page_empty do
5
+ | Select a responsibility from the sidebar, or  
6
+ = ui_link("create a new one.", new_responsibility_path)
@@ -0,0 +1,7 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar"
3
+
4
+ = page_container do
5
+ = page_header("New responsibility", "A scheduled duty a persona carries out on a cron schedule.")
6
+
7
+ = card { render "form", responsibility: @responsibility }
@@ -0,0 +1,22 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar", current: @responsibility
3
+
4
+ = page_container do
5
+ = page_header(@responsibility.name, "#{@responsibility.persona.name} · #{@responsibility.schedule}") do
6
+ div
7
+ = ui_link("Edit", edit_responsibility_path(@responsibility), class: 'px-3')
8
+ = inline_button("Run now", responsibility_runs_path, params: { responsibility_run: { responsibility_id: @responsibility.id } })
9
+
10
+ .space-y-4
11
+ = info_card("Schedule", { "Cron" => @responsibility.schedule, "Persona" => @responsibility.persona.name, "Active" => (@responsibility.active? ? "Yes" : "No") })
12
+
13
+ = card do
14
+ = heading_2("Instructions")
15
+ .mt-3
16
+ = body_text(@responsibility.instructions)
17
+
18
+ = card do
19
+ .space-y-4
20
+ = heading_2("Recent runs")
21
+ - runs = responsibility_runs_table(@responsibility)
22
+ = table(headers: runs.headers, rows: runs.rows, empty: "No runs yet — use Run now to trigger one.")
@@ -0,0 +1,13 @@
1
+ = page_container do
2
+ = page_header("Run · #{@responsibility.name}", @responsibility.persona.name) do
3
+ = ui_link("Back", responsibility_path(@responsibility))
4
+
5
+ .space-y-4
6
+ - details = { "Status" => @run.status, "Enqueued" => @run.created_at&.strftime("%Y-%m-%d %H:%M:%S"), "Started" => @run.started_at&.strftime("%Y-%m-%d %H:%M:%S"), "Finished" => @run.finished_at&.strftime("%Y-%m-%d %H:%M:%S"), "Correlation" => @run.correlation_id }
7
+ = info_card("Run", details, full_width: true)
8
+
9
+ - if @run.failed?
10
+ = card do
11
+ = heading_2("Error")
12
+ .mt-3
13
+ = body_text("#{@run.error_class}: #{@run.error_message}")
@@ -0,0 +1,22 @@
1
+ / Confirmation dialog.
2
+ /
3
+ / Locals:
4
+ / id: unique DOM id for the dialog
5
+ / title: dialog heading
6
+ / message: warning/explanation text
7
+ / action_url: form action URL
8
+ / action_text: confirm button label (default: "Confirm")
9
+ / method: HTTP method (default: :delete)
10
+
11
+ - id ||= "confirm-dialog"
12
+ - action_text ||= "Confirm"
13
+ - method ||= :delete
14
+
15
+ = dialog(id) do
16
+ = heading_3(title)
17
+
18
+ p class="mt-2 text-sm text-gray-600 leading-relaxed"= message
19
+
20
+ div class="mt-6 flex items-center justify-between"
21
+ = button("Cancel", type: "button", onclick: "this.closest('dialog').close()")
22
+ = destructive_link(action_text, action_url, method: method)
@@ -0,0 +1,14 @@
1
+ header class="flex h-14 flex-shrink-0 items-center justify-between px-5" style="border-bottom: 1px solid var(--border); background: var(--surface)"
2
+ div class="flex items-center gap-6"
3
+ = link_to Protege.configuration.nav_title, protege.root_path, class: "flex items-center gap-2 text-sm font-semibold"
4
+
5
+ nav class="flex items-center gap-1"
6
+ = nav_item("Inbox", protege.threads_path, request.path)
7
+ = nav_item("Responsibilities", protege.responsibilities_path, request.path)
8
+ = nav_item("Personas", protege.personas_path, request.path)
9
+ = nav_item("Domains", protege.email_domains_path, request.path)
10
+ = nav_item("Traces", protege.traces_path, request.path)
11
+
12
+ div class="flex items-center gap-3"
13
+ = hotkey_help_button
14
+ = theme_toggle
@@ -0,0 +1,43 @@
1
+ / The whole hotkey surface in one place (see keys.js): the global shortcut registry + the "?" help
2
+ / overlay, rendered on every page.
3
+ /
4
+ / Registry — the single source of truth for app-wide combos. Each is a hidden link keys.js activates on
5
+ / its combo; it navigates via the same route helper the visible UI uses, so a global command works from
6
+ / anywhere, independent of what's on screen. Nav combos are numbered by position (1..5, left to right)
7
+ / rather than by initial — the "go to the Nth section" convention (⌘1..9 in browsers/editors). Rendered
8
+ / after the page body, so a page-local hotkey reusing a combo is matched first and shadows the global one.
9
+ div hidden=true aria-hidden="true"
10
+ = link_to "Documentation", 'https://docs.protege.bot', data: { hotkey: "g 0" }, target: '_blank'
11
+ = link_to "Inbox", protege.threads_path, data: { hotkey: "g 1" }
12
+ = link_to "Responsibilities", protege.responsibilities_path, data: { hotkey: "g 2" }
13
+ = link_to "Personas", protege.personas_path, data: { hotkey: "g 3" }
14
+ = link_to "Domains", protege.email_domains_path, data: { hotkey: "g 4" }
15
+ = link_to "Traces", protege.traces_path, data: { hotkey: "g 5" }
16
+ = link_to "New Thread", protege.new_thread_path, data: { hotkey: "g c" }
17
+ = link_to "New Responsibility", protege.new_responsibility_path, data: { hotkey: "g r" }
18
+ = link_to "New Persona", protege.new_persona_path, data: { hotkey: "g p" }
19
+ = link_to "New Email Domain", protege.new_email_domain_path, data: { hotkey: "g d" }
20
+
21
+ / Help overlay, opened with "?" or the header's "?" button (keys.js just toggles it open/closed). This
22
+ / list is hand-authored — keep it in sync with the registry above; grouped into sections via hotkey_row
23
+ / / hotkey_section (see Components::HotkeysHelper).
24
+ = dialog("protege-hotkey-help", data: { hotkey_help: "" }) do
25
+ = heading_2("Keyboard shortcuts")
26
+ .mt-3.space-y-1
27
+ = hotkey_section("Go to")
28
+ = hotkey_row("Inbox", "g", "1")
29
+ = hotkey_row("Responsibilities", "g", "2")
30
+ = hotkey_row("Personas", "g", "3")
31
+ = hotkey_row("Domains", "g", "4")
32
+ = hotkey_row("Traces", "g", "5")
33
+ = hotkey_row("Documentation", "g", "0")
34
+
35
+ = hotkey_section("Create")
36
+ = hotkey_row("New thread", "g", "c")
37
+ = hotkey_row("New responsibility", "g", "r")
38
+ = hotkey_row("New persona", "g", "p")
39
+ = hotkey_row("New email domain", "g", "d")
40
+
41
+ = hotkey_section("General")
42
+ = hotkey_row("Toggle theme", "g", "t")
43
+ = hotkey_row("Show this menu", "?")
@@ -0,0 +1,15 @@
1
+ / Shared footer for the new-thread and reply composers: Send + shortcut hint on the left, the attach
2
+ / (paperclip) control on the right, and any chosen files as removable badges below. The file input is
3
+ / hidden — attachment_picker.js opens it from the paperclip and manages the badges. The enclosing form
4
+ / must be multipart.
5
+ .space-y-2.flex-shrink-0 data-attachment-picker=true
6
+ = f.file_field :files, multiple: true, class: "hidden", data: { attachment_picker_input: true }
7
+
8
+ .flex.items-center.justify-between.pt-1
9
+ .flex.items-center.gap-3
10
+ = button("Send", type: :submit, data: { hotkey: "mod+enter" })
11
+ = hint("⌘ / Ctrl + ↵")
12
+
13
+ = icon_button(:paperclip, label: "Attach files", data: { attachment_picker_trigger: true })
14
+
15
+ ul.flex.flex-wrap.gap-1.5 hidden=true data-attachment-picker-list=true
@@ -0,0 +1,7 @@
1
+ / Thread introspection panel — the collapsible right sidebar surfacing the agent's live activity for this
2
+ / thread: one chronological feed where reasoning tokens and tool-call cards are interleaved as they
3
+ / happen. Fed entirely by IntrospectionBroadcaster over the thread's Turbo channel (turbo_stream_from
4
+ / @thread in show.html.slim), which appends into the feed container. Live-only — a reload shows the
5
+ / empty-state placeholder until the next run streams in.
6
+ = introspection_panel do
7
+ = introspection_feed(thread)
@@ -0,0 +1,4 @@
1
+ / A reasoning block in the introspection activity feed — one paragraph of streamed reasoning. Appended to
2
+ / the feed by IntrospectionBroadcaster when a reasoning turn opens; that turn's later tokens append into
3
+ / it by id. Locals: id, content.
4
+ = introspection_reasoning(id: id, content: content)
@@ -0,0 +1,7 @@
1
+ div class="relative flex flex-shrink-0 flex-col p-5" style="height: 13rem" data-resize="" data-resize-edge="top" data-resize-min="120" data-resize-max="520" data-resize-key="protege:reply-height"
2
+ = resize_handle(edge: :top)
3
+ = form_with url: thread_reply_path(thread), scope: :reply, multipart: true, html: { class: "flex min-h-0 flex-1 flex-col", data: { reset_on_submit: "" } } do |f|
4
+ = form_text_area(f, :body, placeholder: "Reply...", fill: true)
5
+
6
+ .mt-2
7
+ = render "protege/threads/composer_footer", f: f
@@ -0,0 +1,3 @@
1
+ / A run-lifecycle marker in the introspection activity feed — appended (processing) when a run starts and
2
+ / replaced in place (done / failed) when it ends, by IntrospectionBroadcaster. Locals: id, status, detail.
3
+ = introspection_run_marker(id: id, status: status, detail: detail)
@@ -0,0 +1,15 @@
1
+ - pagy, threads = paginate(threads)
2
+
3
+ = sidebar_content do
4
+ = sidebar_header("Inbox (#{pagy.count})", new_path: new_thread_path, new_label: "Compose")
5
+
6
+ = sidebar_search_link(messages_path, placeholder: "Search emails…")
7
+
8
+ = sidebar_stream("protege:threads", pagy: pagy)
9
+
10
+ = sidebar_body(threads, empty: "No threads yet", id: "threads_list") do |thread|
11
+ = render "protege/threads/thread", \
12
+ thread: thread, \
13
+ active: local_assigns[:active_thread] == thread
14
+
15
+ = sidebar_pagination(pagy)
@@ -0,0 +1,6 @@
1
+ = turbo_frame_tag dom_id(thread) do
2
+ = sidebar_item(thread_title(thread), thread_path(thread, page: params[:page]),
3
+ detail: thread_date(thread),
4
+ subtitle: thread_preview(thread),
5
+ active: local_assigns[:active],
6
+ data: { turbo_frame: "_top" })
@@ -0,0 +1,8 @@
1
+ / One tool-call card in the introspection panel's live activity feed. Rendered from a broadcast with a
2
+ / Protege::IntrospectionToolCallPresentation as +presentation+ — appended as a pending card when the model
3
+ / requests the call, then replaced in place (by its stable card_id) with the completed/failed outcome.
4
+ = introspection_tool_card(id: presentation.card_id,
5
+ name: presentation.tool_name,
6
+ args: presentation.arguments_summary,
7
+ status: presentation.status,
8
+ preview: presentation.preview)
@@ -0,0 +1,6 @@
1
+ - content_for :sidebar do
2
+ = render "protege/threads/sidebar", threads: @threads
3
+
4
+ = page_empty do
5
+ | Select a thread from the sidebar, or  
6
+ = ui_link("start a new one.", new_thread_path)
@@ -0,0 +1,15 @@
1
+ - content_for :sidebar do
2
+ = render "protege/threads/sidebar", threads: inbox_threads
3
+
4
+ = page_container do
5
+ = page_header("New message", "Send a message to a persona to start a thread.")
6
+
7
+ = card do
8
+ = form_with url: threads_path, scope: :thread, class: "space-y-5", multipart: true do |f|
9
+ = form_select(f, :persona_id, available_persona_options, label: "To", prompt: "Select persona...")
10
+
11
+ = form_field(f, :subject, label: "Subject", placeholder: "Subject")
12
+
13
+ = form_text_area(f, :body, label: "Message", rows: 10, placeholder: "Write your message...")
14
+
15
+ = render "protege/threads/composer_footer", f: f
@@ -0,0 +1,25 @@
1
+ - content_for :sidebar do
2
+ = render "protege/threads/sidebar", threads: inbox_threads, active_thread: @thread
3
+
4
+ div class="flex min-h-0 flex-1"
5
+ div class="flex min-h-0 min-w-0 flex-1 flex-col"
6
+ = turbo_stream_from @thread
7
+
8
+ = thread_header(@thread) do
9
+ .flex.items-center.gap-2
10
+ = introspection_toggle
11
+ = button("Delete", type: "button", variant: :danger, onclick: show_dialog("delete-thread"))
12
+
13
+ div id=dom_id(@thread, :messages) class="min-h-0 flex-1 overflow-y-auto px-5"
14
+ = render partial: "protege/messages/message", collection: @messages, as: :message
15
+
16
+ = render "protege/threads/reply_form", thread: @thread
17
+
18
+ = render "protege/threads/introspection", thread: @thread
19
+
20
+ = render("protege/shared/confirm_dialog",
21
+ id: "delete-thread",
22
+ title: "Delete this thread?",
23
+ message: "This will permanently delete the thread and all its messages. This cannot be undone.",
24
+ action_url: thread_path(@thread),
25
+ action_text: "Delete thread")
@@ -0,0 +1,15 @@
1
+ - pagy, page = paginate(traces)
2
+
3
+ = sidebar_content do
4
+ = sidebar_header("Traces (#{pagy.count})")
5
+
6
+ = sidebar_search_link(traces_search_path, placeholder: "Search traces…")
7
+
8
+ = sidebar_body(page, empty: "No traces captured yet", id: "traces_list") do |trace|
9
+ = sidebar_item \
10
+ "#{trace.id} | #{trace.created_at}", \
11
+ trace_path(trace), \
12
+ subtitle: trace.label.humanize, \
13
+ active: local_assigns[:current] == trace
14
+
15
+ = sidebar_pagination(pagy)
@@ -0,0 +1,11 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar", traces: @traces, current: nil
3
+
4
+ = page_empty do
5
+ - if tracing_enabled?
6
+ | Select a captured turn from the sidebar to review and label it.
7
+ - else
8
+ .max-w-sm.space-y-3.text-center
9
+ = hint("Tracing is off — no new turns are being captured.")
10
+ = code_block("config.tracing = { enabled: true }")
11
+ = hint("Set this in your Protege initializer to start collecting.")
@@ -0,0 +1,13 @@
1
+ - pagy, traces = paginate(results)
2
+ - empty_message = search.active? ? "No traces match your search" : "Enter search terms to find traces"
3
+
4
+ = sidebar_content(frame: "search_results") do
5
+ = sidebar_header("Results (#{pagy.count})")
6
+
7
+ = sidebar_body(traces, empty: empty_message) do |trace|
8
+ = sidebar_item(trace.correlation_id.presence&.truncate(28) || "Trace ##{trace.id}", trace_path(trace),
9
+ detail: trace.created_at.to_fs(:short),
10
+ subtitle: trace.model,
11
+ data: { turbo_frame: "_top" })
12
+
13
+ = sidebar_pagination(pagy)
@@ -0,0 +1,22 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar", search: @search, results: @traces
3
+
4
+ = page_container do
5
+ = page_header("Search traces", "Find captured turns by run, model, request or response text, note, or label. Matches appear in the sidebar.")
6
+
7
+ = card do
8
+ = form_with url: traces_search_path, method: :get, data: { turbo_frame: "search_results" }, class: "space-y-5" do |f|
9
+ = form_field(f, :correlation_id, label: "Run", placeholder: "correlation id contains…", value: params[:correlation_id], onblur: "this.form.requestSubmit()")
10
+
11
+ = form_field(f, :model, label: "Model", placeholder: "model contains…", value: params[:model], onblur: "this.form.requestSubmit()")
12
+
13
+ = form_select(f, :label, [["Any", ""]] + Protege::Trace.labels.keys.map { |l| [l.humanize, l] }, label: "Label", selected: params[:label], onchange: "this.form.requestSubmit()")
14
+
15
+ = form_field(f, :request, label: "Request", placeholder: "prompt or tools contain…", value: params[:request], onblur: "this.form.requestSubmit()")
16
+
17
+ = form_field(f, :response, label: "Response", placeholder: "output contains…", value: params[:response], onblur: "this.form.requestSubmit()")
18
+
19
+ = form_field(f, :annotation, label: "Annotation", placeholder: "note contains…", value: params[:annotation], onblur: "this.form.requestSubmit()")
20
+
21
+ .pt-2
22
+ = button("Search", type: :submit)
@@ -0,0 +1,28 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar", traces: recent_traces, current: @trace
3
+
4
+ = page_container do
5
+ = page_header("Turn #{@trace.turn_index}", @trace.model.presence || "no model recorded") do
6
+ = ui_link("Back", traces_path)
7
+
8
+ .space-y-4
9
+ = card do
10
+ = heading_2("Review")
11
+ = form_with model: @trace, url: trace_path(@trace), method: :patch, class: "mt-3 space-y-4" do |f|
12
+ = form_select(f, :label, Protege::Trace.labels.keys.map { |l| [l.humanize, l] }, label: "Label")
13
+ = form_text_area(f, :annotation, label: "Note", placeholder: "Why is this turn good/bad? (optional)")
14
+ .pt-2
15
+ = button("Save review", type: :submit)
16
+
17
+ - meta = { "Correlation ID" => @trace.correlation_id, "Model" => @trace.model, "Settings" => @trace.settings.presence&.to_json, "Label" => @trace.label.humanize, "Reviewed" => @trace.reviewed_at&.strftime("%Y-%m-%d %H:%M"), "Captured" => @trace.created_at.strftime("%Y-%m-%d %H:%M") }
18
+ = info_card("Turn", meta.compact, full_width: true)
19
+
20
+ = card do
21
+ = heading_2("Request")
22
+ pre class="mt-3 overflow-x-auto text-xs" style="white-space: pre-wrap; word-break: break-word"
23
+ = JSON.pretty_generate(@trace.request)
24
+
25
+ = card do
26
+ = heading_2("Response")
27
+ pre class="mt-3 overflow-x-auto text-xs" style="white-space: pre-wrap; word-break: break-word"
28
+ = JSON.pretty_generate(@trace.response)
data/config/routes.rb ADDED
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Never use 'only' or 'except' in a Rails engine's routes.rb: Any missing controller action will return 404, just
4
+ # like a missing route, so the engine's routes are always fully defined. The host app can override any route by
5
+ # defining it in its own routes.rb, but it cannot remove a route from the engine.
6
+ Protege::Engine.routes.draw do
7
+ root to: 'home#show'
8
+
9
+ # Trace search is its own view — Protege::Traces::SearchesController#show, reached from the traces
10
+ # sidebar's search link. Declared before +resources :traces+ so /traces/search resolves here rather
11
+ # than being captured as /traces/:id.
12
+ namespace :traces do
13
+ resource :search
14
+ end
15
+
16
+ resources :traces
17
+ resources :messages
18
+ resources :responsibilities
19
+ resources :responsibility_runs
20
+
21
+ resources :threads do
22
+ resource :reply
23
+ end
24
+
25
+ resources :personas do
26
+ resource :archive
27
+ resources :access_rules
28
+ end
29
+
30
+ resources :email_domains do
31
+ resource :mta_domains_sync
32
+ end
33
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Consolidated install migration for all Protege engine tables — the squashed replacement for the
4
+ # engine's incremental history, collapsed once every environment was synced to the final schema. It
5
+ # reuses the original first-migration version (20260520000001): any database that has ever run the engine
6
+ # already records this version and skips it, so only fresh installs execute it.
7
+ class CreateProtegeTables < ActiveRecord::Migration[7.2]
8
+ def change
9
+ create_table :protege_personas do |t|
10
+ t.string :type, null: false
11
+ t.string :name, null: false, index: { unique: true }
12
+ t.string :email_address, null: false, index: { unique: true }
13
+ t.boolean :active, null: false, default: true
14
+ t.text :instructions
15
+ t.timestamps
16
+ end
17
+
18
+ create_table :protege_email_domains do |t|
19
+ t.string :domain, null: false, index: { unique: true }
20
+ t.string :dkim_selector, null: false, default: 'default'
21
+ t.text :dkim_private_key_pem, null: false
22
+ t.timestamps
23
+ end
24
+
25
+ create_table :protege_email_threads do |t|
26
+ t.references :persona, null: false, foreign_key: { to_table: :protege_personas }
27
+ t.string :thread_id, null: false, index: { unique: true }
28
+ t.string :subject
29
+ t.integer :message_count, null: false, default: 0
30
+ t.datetime :last_message_at, index: true
31
+ t.timestamps
32
+ end
33
+
34
+ create_table :protege_messages do |t|
35
+ t.references :email_thread, null: false, foreign_key: { to_table: :protege_email_threads }
36
+ t.references :persona, null: false, foreign_key: { to_table: :protege_personas }
37
+ t.references :inbound_email, null: true, foreign_key: { to_table: :action_mailbox_inbound_emails }
38
+ t.string :message_id, null: false, index: { unique: true }
39
+ t.string :direction, null: false, index: true
40
+ t.string :from_address, null: false
41
+ t.text :to_addresses
42
+ t.text :cc_addresses
43
+ t.text :bcc_addresses
44
+ t.string :reply_to
45
+ t.string :subject
46
+ t.string :in_reply_to
47
+ t.text :references_header
48
+ t.text :text_body
49
+ t.text :html_body
50
+ t.datetime :sent_at, null: false, index: true
51
+ t.string :correlation_id, index: true
52
+ t.string :processing_status, index: true
53
+ t.datetime :processing_started_at
54
+ t.datetime :processing_completed_at
55
+ t.integer :processing_attempts, null: false, default: 0
56
+ t.text :last_processing_error
57
+ t.datetime :delivered_at
58
+ t.text :delivery_error
59
+ t.datetime :read_at
60
+ t.timestamps
61
+ end
62
+
63
+ create_table :protege_access_rules do |t|
64
+ t.references :persona, null: false, foreign_key: { to_table: :protege_personas }
65
+ t.string :kind, null: false
66
+ t.string :pattern, null: false
67
+ t.timestamps
68
+ end
69
+
70
+ create_table :protege_responsibilities do |t|
71
+ t.references :persona, null: false, foreign_key: { to_table: :protege_personas }
72
+ t.string :name, null: false
73
+ t.text :instructions, null: false
74
+ t.string :schedule, null: false
75
+ t.boolean :active, null: false, default: true
76
+ t.datetime :last_run_at
77
+ t.timestamps
78
+ end
79
+
80
+ create_table :protege_responsibility_runs do |t|
81
+ t.references :responsibility, null: false, foreign_key: { to_table: :protege_responsibilities }
82
+ t.string :status, null: false, default: 'pending', index: true
83
+ t.datetime :started_at
84
+ t.datetime :finished_at
85
+ t.string :error_class
86
+ t.text :error_message
87
+ t.string :correlation_id
88
+ t.timestamps
89
+ end
90
+
91
+ create_table :protege_tool_uses do |t|
92
+ t.references :source, polymorphic: true, null: false
93
+ t.references :persona, null: false, foreign_key: { to_table: :protege_personas }
94
+ t.string :run_id
95
+ t.integer :turn_index, null: false
96
+ t.integer :position, null: false
97
+ t.string :tool_call_id
98
+ t.string :tool_name, null: false
99
+ t.text :arguments
100
+ t.text :result
101
+ t.boolean :succeeded, null: false, default: false
102
+ t.timestamps
103
+ end
104
+ end
105
+ end