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,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Manages scheduled responsibilities — the Loop layer's dashboard CRUD. A top-level section (like
5
+ # Personas or Traces): responsibilities are listed across all personas, and each one names its owning
6
+ # persona in the form rather than carrying it in the URL.
7
+ class ResponsibilitiesController < ApplicationController
8
+ before_action :set_responsibility, only: %i[show edit update destroy]
9
+
10
+ # List every responsibility, newest-owner-agnostic (ordered by name via the model's default scope).
11
+ #
12
+ # @return [void]
13
+ def index
14
+ @responsibilities = Responsibility.includes(:persona)
15
+ end
16
+
17
+ # Show a single responsibility and its recent runs.
18
+ #
19
+ # @return [void]
20
+ def show; end
21
+
22
+ # Render the form for creating a responsibility.
23
+ #
24
+ # @return [void]
25
+ def new
26
+ @responsibility = Responsibility.new
27
+ end
28
+
29
+ # Render the form for editing a responsibility.
30
+ #
31
+ # @return [void]
32
+ def edit; end
33
+
34
+ # Create a responsibility (its persona is chosen in the form). On failure, re-render with errors.
35
+ #
36
+ # @return [void]
37
+ def create
38
+ @responsibility = Responsibility.new(responsibility_params)
39
+
40
+ if @responsibility.save
41
+ redirect_to responsibility_path(@responsibility), notice: 'Responsibility created.'
42
+ else
43
+ render :new, status: :unprocessable_entity
44
+ end
45
+ end
46
+
47
+ # Update a responsibility. On failure, re-render the edit form with validation errors.
48
+ #
49
+ # @return [void]
50
+ def update
51
+ if @responsibility.update(responsibility_params)
52
+ redirect_to responsibility_path(@responsibility), notice: 'Responsibility updated.'
53
+ else
54
+ render :edit, status: :unprocessable_entity
55
+ end
56
+ end
57
+
58
+ # Remove a responsibility.
59
+ #
60
+ # @return [void]
61
+ def destroy
62
+ @responsibility.destroy
63
+ redirect_to responsibilities_path, notice: 'Responsibility deleted.'
64
+ end
65
+
66
+ private
67
+
68
+ # Load the responsibility named by the route into +@responsibility+ (its persona via the association).
69
+ #
70
+ # @return [Protege::Responsibility] the loaded responsibility
71
+ def set_responsibility
72
+ @responsibility = Responsibility.find(params[:id])
73
+ end
74
+
75
+ # Permit the editable responsibility attributes, including the +:persona_id+ the form now supplies.
76
+ #
77
+ # @return [ActionController::Parameters] the permitted attributes
78
+ def responsibility_params
79
+ params.require(:responsibility).permit(:persona_id, :name, :instructions, :schedule, :active)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Surfaces individual runs of a responsibility — the Loop layer's run records. Creating a run is how
5
+ # the dashboard's "Run now" works: dispatching a responsibility immediately, modelled RESTfully as
6
+ # POSTing a new run rather than a custom action.
7
+ class ResponsibilityRunsController < ApplicationController
8
+ before_action :set_responsibility_run, only: [:show]
9
+
10
+ # Dispatch a responsibility now, recording a pending run and enqueuing its job. Serves
11
+ # +POST /responsibility_runs+.
12
+ #
13
+ # @return [void]
14
+ def create
15
+ responsibility = Responsibility.find(run_params[:responsibility_id])
16
+
17
+ @run = responsibility.dispatch!
18
+
19
+ redirect_to responsibility_run_path(@run), notice: 'Run enqueued.'
20
+ end
21
+
22
+ # Show one run's outcome — status, timing, and any failure detail. Serves
23
+ # +GET /responsibility_runs/:id+.
24
+ #
25
+ # @return [void]
26
+ def show
27
+ @responsibility = @run.responsibility
28
+ end
29
+
30
+ private
31
+
32
+ # Load the run record
33
+ #
34
+ # @return [Protege::ResponsibilityRun] the run to show
35
+ def set_responsibility_run
36
+ @run = ResponsibilityRun.find(params[:id])
37
+ end
38
+
39
+ # Permit the responsibility a "Run now" targets.
40
+ #
41
+ # @return [ActionController::Parameters] the permitted +:responsibility_id+
42
+ def run_params
43
+ params.require(:responsibility_run).permit(:responsibility_id)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Manages email threads — the inbox list, individual thread views,
5
+ # and composing new conversations from the dashboard console.
6
+ class ThreadsController < ApplicationController
7
+ before_action :set_thread, only: %i[show destroy]
8
+
9
+ # List the inbox threads. Serves +GET /threads+.
10
+ #
11
+ # @return [void]
12
+ def index
13
+ @threads = EmailThread.for_inbox
14
+ end
15
+
16
+ # Show a thread with its messages newest-first. Serves +GET /threads/:id+.
17
+ #
18
+ # Liveness (the agent's reasoning and tool use) streams into the introspection panel, so the thread
19
+ # view itself is static — no per-message processing indicator to compute here.
20
+ #
21
+ # @return [void]
22
+ def show
23
+ @messages = @thread.messages.order(sent_at: :desc)
24
+ end
25
+
26
+ # Render the compose form for a new conversation. Serves +GET /threads/new+.
27
+ #
28
+ # @return [void]
29
+ def new; end
30
+
31
+ # Compose and dispatch a new conversation through the +Gateway+. Serves +POST /threads+.
32
+ #
33
+ # Uses the same host-facing seam application code would — +Gateway.message+ — passing any uploaded
34
+ # files straight through as attachments. Redirects to the thread created for the resulting message.
35
+ #
36
+ # @return [void]
37
+ def create
38
+ persona = Persona.find(compose_params[:persona_id])
39
+
40
+ message = Gateway.message(
41
+ to: persona.email_address,
42
+ subject: compose_params[:subject],
43
+ body: compose_params[:body],
44
+ attachments: compose_params[:files] || []
45
+ )
46
+
47
+ redirect_to thread_path(message.email_thread)
48
+ end
49
+
50
+ # Delete a thread and all its messages. Serves +DELETE /threads/:id+.
51
+ #
52
+ # @return [void]
53
+ def destroy
54
+ @thread.destroy!
55
+
56
+ redirect_to threads_path
57
+ end
58
+
59
+ private
60
+
61
+ # Load the +EmailThread+ named by +params[:id]+ into +@thread+.
62
+ #
63
+ # @return [Protege::EmailThread] the loaded thread
64
+ def set_thread
65
+ @thread = EmailThread.find(params[:id])
66
+ end
67
+
68
+ # Permit the compose-form attributes for a new conversation.
69
+ #
70
+ # @return [ActionController::Parameters] the permitted +:persona_id+, +:subject+, +:body+, +:files+
71
+ def compose_params
72
+ params.require(:thread).permit(:persona_id, :subject, :body, files: [])
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Traces
5
+ # Trace search — the console's cross-run finder over captured turns (see +TraceSearch+), reached from
6
+ # the traces sidebar's search link. Nested in the +Traces+ module because search is a view *of*
7
+ # traces, not a child record a trace owns (unlike a thread, which genuinely has messages).
8
+ class SearchesController < Protege::ApplicationController
9
+ # Show the search form and, in the sidebar, the traces matching the query params (see
10
+ # +TraceSearch+). Blank params yield no results. Serves +GET /traces/search+.
11
+ #
12
+ # @return [void]
13
+ def show
14
+ @search = TraceSearch.from_params(params)
15
+ @traces = @search.results
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Traces — the review surface for captured inference turns (see +Protege::Trace+). +index+ lists the
5
+ # turns (newest first, optionally narrowed to the unlabeled review queue) in the sidebar; +show+
6
+ # renders one turn's request/response/settings snapshot; +update+ applies a human review label so the
7
+ # turn becomes usable training data. Read-and-label only — traces are written by the tracing
8
+ # subscriber, never edited here beyond the verdict.
9
+ class TracesController < ApplicationController
10
+ before_action :set_trace, only: %i[show update]
11
+
12
+ # List captured turns, newest first, for the sidebar (search lives on its own page — see
13
+ # +Traces::SearchesController+). Serves +GET /traces+.
14
+ #
15
+ # @return [void]
16
+ def index
17
+ @traces = Trace.recent
18
+ end
19
+
20
+ # Show one turn's snapshot and its review form. The sidebar sources its own list via the
21
+ # +recent_traces+ helper (like threads' +inbox_threads+), so nothing to load here. Serves
22
+ # +GET /traces/:id+.
23
+ #
24
+ # @return [void]
25
+ def show; end
26
+
27
+ # Apply a review verdict (label + optional note) to a turn. Serves +PATCH /traces/:id+.
28
+ #
29
+ # @return [void]
30
+ def update
31
+ @trace.apply_label(label: label_params[:label], annotation: label_params[:annotation])
32
+
33
+ redirect_to trace_path(@trace), notice: 'Trace labeled.'
34
+ end
35
+
36
+ private
37
+
38
+ # Load the trace named by +params[:id]+.
39
+ #
40
+ # @return [Protege::Trace] the loaded trace
41
+ def set_trace
42
+ @trace = Trace.find(params[:id])
43
+ end
44
+
45
+ # Permit the review verdict fields.
46
+ #
47
+ # @return [ActionController::Parameters] the permitted +:label+/+:annotation+
48
+ def label_params
49
+ params.require(:trace).permit(:label, :annotation)
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # The single aggregator of every engine helper module. The engine registers only this module on the
5
+ # view context (+helper Protege::ApplicationHelper+ in ApplicationController), so including every
6
+ # helper here is what makes their methods available in all render contexts — request views, partials,
7
+ # and Turbo broadcast renders alike. Two groups: the reusable, model-agnostic UI toolkit under
8
+ # +Protege::Components::+ — pulled in as a whole via its own aggregator, +Components::ComponentsHelper+,
9
+ # which includes the rest of the group (typography, tables, badges, buttons, cards, dialogs, menus,
10
+ # forms, layout, sidebar, pagination, the introspection panel) — and the model-named helpers at the
11
+ # top level (threads, personas, email
12
+ # domains, messages, responsibilities, traces). Any cross-helper call resolves through the view, which
13
+ # has them all via this module.
14
+ module ApplicationHelper
15
+ include Components::ComponentsHelper
16
+ include EmailDomainsHelper
17
+ include MessagesHelper
18
+ include PersonasHelper
19
+ include ResponsibilitiesHelper
20
+ include ThreadsHelper
21
+ include TraceHelper
22
+
23
+ # List of JS files to be added to the console layout
24
+ #
25
+ # @return [Array<string>] javascript asset file paths.
26
+ def js_files
27
+ %w[protege/theme protege/resize protege/forms protege/copy protege/keys protege/attachment_picker]
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Components
5
+ # Compact status indicators: the pill +badge+ and the labelled +status_dot+. Both take a semantic
6
+ # color and resolve it through the +badge_colors+ / +dot_colors+ maps defined alongside them, so the
7
+ # palette lives with the components that use it.
8
+ module BadgesHelper
9
+ # Render a small pill badge tinted by semantic color.
10
+ #
11
+ # @param text [String] the badge label
12
+ # @param color [Symbol] the semantic color (see +badge_colors+)
13
+ # @return [ActiveSupport::SafeBuffer] the badge markup
14
+ def badge(text, color = :gray)
15
+ _, fg = badge_colors(color)
16
+ tag.span(text, class: "inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium #{fg}",
17
+ style: 'background: var(--surface-subtle); border: 1px solid var(--border)')
18
+ end
19
+
20
+ # Map a semantic badge color to its +[background, foreground]+ classes.
21
+ #
22
+ # @param color [Symbol] the semantic color
23
+ # @return [Array(nil, String)] the background and foreground class pair
24
+ def badge_colors(color)
25
+ case color
26
+ when :green then [nil, 'text-green-600']
27
+ when :red then [nil, 'text-red-600']
28
+ when :yellow then [nil, 'text-yellow-600']
29
+ else [nil, '']
30
+ end
31
+ end
32
+
33
+ # Render a colored status dot followed by its label.
34
+ #
35
+ # @param text [String] the status label
36
+ # @param color [Symbol] the semantic color (see +dot_colors+)
37
+ # @return [ActiveSupport::SafeBuffer] the status-dot markup
38
+ def status_dot(text, color = :gray)
39
+ dot_color = dot_colors(color)
40
+ tag.span(class: 'inline-flex items-center gap-1.5 text-xs', style: 'color: var(--text-muted)') do
41
+ tag.span('', class: "h-1.5 w-1.5 rounded-full #{dot_color}") + text
42
+ end
43
+ end
44
+
45
+ # Map a semantic status color to its dot background class.
46
+ #
47
+ # @param color [Symbol] the semantic color
48
+ # @return [String] the Tailwind background class for the dot
49
+ def dot_colors(color)
50
+ case color
51
+ when :green then 'bg-green-600'
52
+ when :red then 'bg-red-600'
53
+ when :yellow then 'bg-yellow-600'
54
+ else 'bg-gray-500'
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Components
5
+ # Clickable actions: the primary/danger +button+, the solid form-submitting +action_button+ (its
6
+ # +button_to+ counterpart, sharing the same look via +button_class+), the amber text +ui_link+, and
7
+ # the two link-styled form buttons (+inline_button+, +destructive_link+) for subtler POST/DELETE
8
+ # actions. Variant styling is resolved by +button_variant_style+, defined alongside them here.
9
+ module ButtonsHelper
10
+ # Render a styled button. The +:danger+ variant uses the danger hover style.
11
+ #
12
+ # @param text [String] the button label
13
+ # @param variant [Symbol] the visual variant (see +button_variant_style+)
14
+ # @param attrs [Hash] extra HTML attributes forwarded to the button
15
+ # @return [ActiveSupport::SafeBuffer] the button markup
16
+ def button(text, variant: :primary, **attrs)
17
+ tag.button(text,
18
+ class: button_class(variant, attrs.delete(:class)),
19
+ style: button_variant_style(variant),
20
+ **attrs)
21
+ end
22
+
23
+ # Render a solid, button-styled action that submits a form — a +button_to+ (so it POSTs, unlike the
24
+ # formless {#button}) wearing the same styling as {#button}. Use this for actions that mutate state
25
+ # from a plain click (e.g. "Sync now"); the link-styled {#inline_button}/{#destructive_link} are the
26
+ # subtler variants.
27
+ #
28
+ # @param text [String] the button label
29
+ # @param href [String] the form action target
30
+ # @param method [Symbol] the HTTP verb the button submits (default +:post+)
31
+ # @param variant [Symbol] the visual variant (see +button_variant_style+)
32
+ # @param attrs [Hash] extra HTML attributes forwarded to the button
33
+ # @return [ActiveSupport::SafeBuffer] the button-to markup
34
+ def action_button(text, href, method: :post, variant: :primary, **attrs)
35
+ button_to text, href,
36
+ method:,
37
+ class: button_class(variant, attrs.delete(:class)),
38
+ style: button_variant_style(variant),
39
+ form: { class: 'inline' },
40
+ **attrs
41
+ end
42
+
43
+ # Render a form button as a link-styled element, for use in inline
44
+ # forms (e.g. +button_to+ with +form: { class: "inline" }).
45
+ #
46
+ # @param text [String] the button text
47
+ # @param href [String] the form action target
48
+ # @param method [Symbol] the HTTP verb the button submits
49
+ # @param attrs [Hash] extra HTML attributes forwarded to the button
50
+ # @return [ActiveSupport::SafeBuffer] the button markup
51
+ def inline_button(text, href, method: :post, **attrs)
52
+ classes = 'text-sm text-amber-600 hover:text-amber-700 cursor-pointer ' \
53
+ "bg-transparent border-0 p-0 #{attrs.delete(:class)}"
54
+
55
+ button_to text, href,
56
+ method:,
57
+ class: classes,
58
+ form: { class: 'inline' },
59
+ **attrs
60
+ end
61
+
62
+ # Render an amber text link, dimmed when +muted+.
63
+ #
64
+ # @param text [String] the link text
65
+ # @param href [String] the link target
66
+ # @param muted [Boolean] whether to render the dimmed variant
67
+ # @param attrs [Hash] extra HTML attributes forwarded to the link
68
+ # @return [ActiveSupport::SafeBuffer] the link markup
69
+ def ui_link(text, href, muted: false, **attrs)
70
+ classes = if muted
71
+ 'text-sm text-amber-400 hover:text-amber-600'
72
+ else
73
+ 'text-sm text-amber-600 hover:text-amber-700'
74
+ end
75
+
76
+ link_to(text, href, class: "#{classes} #{attrs.delete(:class)}", **attrs)
77
+ end
78
+
79
+ # Render a destructive action as an inline form button with optional confirm.
80
+ #
81
+ # @param text [String] the link text
82
+ # @param href [String] the action target
83
+ # @param confirm [String, nil] optional Turbo confirmation prompt
84
+ # @param method [Symbol] the HTTP verb the button submits
85
+ # @return [ActiveSupport::SafeBuffer] the button-to markup
86
+ # @param [Hash{Symbol => Object}] attrs
87
+ def destructive_link(text, href, confirm: nil, method: :delete, **attrs)
88
+ classes = 'text-sm text-red-500 hover:text-red-700 cursor-pointer ' \
89
+ "bg-transparent border-0 p-0 #{attrs.delete(:class)}"
90
+
91
+ button_to text, href,
92
+ method:,
93
+ class: classes,
94
+ form: { class: 'inline' },
95
+ data: confirm ? { turbo_confirm: confirm } : {},
96
+ **attrs
97
+ end
98
+
99
+ # Render an icon-only button — a muted glyph that brightens on hover (e.g. a composer's attach
100
+ # control). +label+ is both the tooltip and the accessible name, since there is no visible text.
101
+ #
102
+ # @param name [Symbol] the icon to render (see {IconsHelper#icon})
103
+ # @param label [String] the tooltip / accessible label
104
+ # @param attrs [Hash] extra HTML attributes forwarded to the button (e.g. +data:+)
105
+ # @return [ActiveSupport::SafeBuffer] the icon-button markup
106
+ def icon_button(name, label:, **attrs)
107
+ tag.button(icon(name),
108
+ type: 'button',
109
+ title: label,
110
+ 'aria-label': label,
111
+ class: 'rounded p-1.5 opacity-70 transition-opacity hover:opacity-100 cursor-pointer',
112
+ style: 'color: var(--text-muted)',
113
+ **attrs)
114
+ end
115
+
116
+ # The shared class list for the solid button look, worn by both {#button} and {#action_button}.
117
+ #
118
+ # @param variant [Symbol] the button variant (only +:danger+ changes the hover treatment)
119
+ # @param extra [String, nil] caller-supplied extra classes
120
+ # @return [String] the combined class string
121
+ def button_class(variant, extra = nil)
122
+ hover_class = variant == :danger ? 'btn-danger' : 'btn-outline'
123
+ base_class = 'inline-flex items-center justify-center rounded-sm px-2.5 py-1 text-xs ' \
124
+ 'font-semibold uppercase tracking-wide transition-colors cursor-pointer'
125
+ class_names(hover_class, base_class, extra)
126
+ end
127
+
128
+ # Map a button variant to its inline style string.
129
+ #
130
+ # @param variant [Symbol] the button variant
131
+ # @return [String] the variant's CSS style string
132
+ def button_variant_style(variant)
133
+ case variant
134
+ when :secondary then 'background: transparent; border: 1px solid var(--border); color: var(--text-muted)'
135
+ when :danger then 'background: transparent; border: 1px solid #ef4444; color: #ef4444'
136
+ else 'background: transparent; border: 1px solid var(--border); color: var(--text)'
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Components
5
+ # Surface-card UI elements shared across engine views. One helper among the +Components+ peers pulled
6
+ # together by +ComponentsHelper+; +info_card+ calls +heading_2+ (+TypographyHelper+), resolved through
7
+ # the view, which has every helper.
8
+ #
9
+ # Colors use CSS custom properties (--surface, --border, --text-faint) defined
10
+ # in tailwind/application.css so dark mode works via a single .dark class toggle.
11
+ module CardsHelper
12
+ # Render a surface card, padded unless +padding+ is false.
13
+ #
14
+ # @param padding [Boolean] whether to apply interior padding
15
+ # @yield the card body content
16
+ # @return [ActiveSupport::SafeBuffer] the card markup
17
+ def card(padding: true, &)
18
+ tag.div(class: "rounded-xl #{'p-6' if padding}",
19
+ style: 'background: var(--surface); border: 1px solid var(--border)', &)
20
+ end
21
+
22
+ # Render a titled card of label/value pairs in a monospace value column.
23
+ #
24
+ # By default the label sits in a fixed-width column beside the value — right for short labels like
25
+ # "Address". Pass +full_width: true+ to let the label take its natural width so the value uses the
26
+ # full row, for longer labels (e.g. resolver class names) that would overflow the fixed column.
27
+ #
28
+ # @param title [String] the card title
29
+ # @param pairs [Hash{String => String}] the label => value rows
30
+ # @param full_width [Boolean] let the label size to its content instead of a fixed column
31
+ # @return [ActiveSupport::SafeBuffer] the info-card markup
32
+ def info_card(title, pairs = {}, full_width: false)
33
+ tag.div(class: 'rounded-xl p-5',
34
+ style: 'background: var(--surface); border: 1px solid var(--border)') do
35
+ tag.div(class: 'mb-3') { heading_2(title) } +
36
+ tag.div(class: 'space-y-1 text-sm') do
37
+ safe_join(pairs.map do |label, value|
38
+ tag.div(class: 'flex gap-8') do
39
+ tag.span(label, class: (full_width ? 'shrink-0' : 'w-20'), style: 'color: var(--text-faint)') +
40
+ tag.code(value, class: 'font-mono break-all')
41
+ end
42
+ end)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Components
5
+ # The aggregator for the +Protege::Components::+ UI toolkit. It holds no methods of its own — every
6
+ # component lives in a focused, self-describing helper (typography, tables, badges, buttons, dialogs,
7
+ # menus, cards, forms, layout, sidebar, pagination, introspection) — and this module includes them
8
+ # all, so +Protege::ApplicationHelper+ pulls the whole toolkit into the view with a single
9
+ # +include Components::ComponentsHelper+. Because they land on the view together, any component can
10
+ # freely call another (e.g. +info_card+ renders a +heading_2+) through the view.
11
+ #
12
+ # Colors throughout use CSS custom properties (--surface, --border, --text, --text-muted,
13
+ # --text-faint) defined in tailwind/application.css so dark mode works via a single .dark class toggle.
14
+ module ComponentsHelper
15
+ include BadgesHelper
16
+ include ButtonsHelper
17
+ include CardsHelper
18
+ include DialogsHelper
19
+ include FormsHelper
20
+ include HotkeysHelper
21
+ include IntrospectionHelper
22
+ include LayoutHelper
23
+ include MenusHelper
24
+ include PaginationHelper
25
+ include SidebarHelper
26
+ include TablesHelper
27
+ include TypographyHelper
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Components
5
+ # Native modal +<dialog>+ elements: +dialog+ renders one (closing on backdrop click), and +show_dialog+
6
+ # builds the +showModal()+ snippet a trigger fires to open it by id.
7
+ module DialogsHelper
8
+ # Build the JS snippet that opens a +<dialog>+ by element id.
9
+ #
10
+ # @param element_id [String] the DOM id of the target dialog
11
+ # @return [String] the +showModal()+ invocation
12
+ def show_dialog(element_id)
13
+ "document.getElementById('#{element_id}').showModal()"
14
+ end
15
+
16
+ # Render a modal +<dialog>+ that closes on backdrop click.
17
+ #
18
+ # @param element_id [String] the DOM id assigned to the dialog
19
+ # @yield the dialog body content
20
+ # @return [ActiveSupport::SafeBuffer] the dialog markup
21
+ # @param [Hash{Symbol => Object}] attrs
22
+ def dialog(element_id, **attrs, &)
23
+ tag.dialog(
24
+ id: element_id,
25
+ # focus:outline-none — showModal() focuses the dialog itself when it has no focusable child
26
+ # (e.g. the hotkey-help overlay), which would otherwise draw the UA focus ring on the whole box.
27
+ class: 'rounded-xl p-6 shadow-lg backdrop:bg-gray-900/50 max-w-md w-full m-auto focus:outline-none',
28
+ style: 'background: var(--surface); border: 1px solid var(--border); color: var(--text)',
29
+ onclick: 'event.target === this && this.close()',
30
+ **attrs,
31
+ &
32
+ )
33
+ end
34
+ end
35
+ end
36
+ end