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,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'resolv'
4
+ require 'ipaddr'
5
+
6
+ module Protege
7
+ # Built-in web-fetch tool — the way an agent reads a page or file from the open web. Subclasses
8
+ # +Protege::Tool+, so the harness publishes it in the LLM's tool catalog (id +:web_fetch+); when
9
+ # the model emits a +web_fetch+ call, the harness routes the arguments to +#use+.
10
+ #
11
+ # It is deliberately conservative, because the URL is agent-supplied and could be steered by prompt
12
+ # injection: only +http+/+https+ is allowed; hosts that resolve to private, loopback, or link-local
13
+ # addresses are refused (an SSRF guard, re-checked on every redirect) so the agent can never reach
14
+ # internal network endpoints; redirects are followed manually up to {MAX_REDIRECTS}; and the response
15
+ # is size-capped. HTML is reduced to readable text (tags stripped); other text-like content is
16
+ # returned as-is; binary content (images, PDFs, video) is refused — use +read_attachment+ for files
17
+ # the agent already has.
18
+ class WebFetchTool < Protege::Tool
19
+ # Maximum number of redirect hops followed before giving up.
20
+ MAX_REDIRECTS = 3
21
+
22
+ # Hard ceiling (from the +Content-Length+ header) above which the fetch is refused outright.
23
+ MAX_BYTES = 5_000_000
24
+
25
+ # Returned text is truncated to this many characters (with +truncated: true+) so a huge page can
26
+ # never blow up the model's context.
27
+ MAX_CONTENT_CHARS = 100_000
28
+
29
+ # Connection- and read-timeouts (seconds) for the HTTP request.
30
+ OPEN_TIMEOUT = 5
31
+ READ_TIMEOUT = 10
32
+
33
+ # HTTP statuses treated as redirects (the +Location+ header is followed).
34
+ REDIRECT_STATUSES = [301, 302, 303, 307, 308].freeze
35
+
36
+ description <<~DESC.strip
37
+ Fetch a web page or text file by URL and return its readable contents. Use this to read a page
38
+ you found (e.g. via web_search) or a link someone sent you.
39
+
40
+ Provide an absolute http or https url. HTML pages are returned as plain readable text (markup
41
+ stripped); plain-text and JSON are returned as-is. Very large pages are truncated (you'll see
42
+ truncated: true). Binary files (images, PDFs, video) cannot be read this way. Requests to
43
+ internal/private network addresses are refused.
44
+ DESC
45
+
46
+ input_schema(
47
+ type: 'object',
48
+ additionalProperties: false,
49
+ required: %w[url],
50
+ properties: {
51
+ url: {
52
+ type: 'string',
53
+ description: 'The absolute http(s) URL to fetch.'
54
+ }
55
+ }
56
+ )
57
+
58
+ # Fetch the URL and return its readable text.
59
+ #
60
+ # @param context [Protege::Orchestrator::Context] the per-delivery tool context (unused)
61
+ # @param url [String] the absolute http(s) URL supplied by the model
62
+ # @return [Protege::Result] success carrying the final +url+, +status+, +content_type+, +content+,
63
+ # and +truncated+ flag, or a failure (bad scheme, blocked address, non-2xx, non-text, timeout, or
64
+ # too many redirects)
65
+ def use(context:, url:)
66
+ fetch(url:, redirects_left: MAX_REDIRECTS)
67
+ rescue Faraday::Error => e
68
+ failure(reason: "could not fetch #{url}: #{e.message}")
69
+ end
70
+
71
+ private
72
+
73
+ # Resolve, guard, and fetch a single URL, following one redirect by recursing with a decremented
74
+ # budget. Every hop re-validates the scheme and the SSRF guard against the (possibly new) host.
75
+ #
76
+ # @param url [String] the URL to fetch on this hop
77
+ # @param redirects_left [Integer] remaining redirect budget
78
+ # @return [Protege::Result] the outcome
79
+ def fetch(url:, redirects_left:)
80
+ uri = parse_http_uri(url)
81
+ return failure(reason: "#{url.inspect} is not a valid http(s) URL") unless uri
82
+ if blocked?(uri.hostname)
83
+ return failure(reason: "refusing to fetch a private or internal address (#{uri.hostname})")
84
+ end
85
+
86
+ response = connection.get(uri.to_s)
87
+ return follow_redirect(uri:, response:, redirects_left:) if REDIRECT_STATUSES.include?(response.status)
88
+ unless (200..299).cover?(response.status)
89
+ return failure(reason: "fetch of #{uri} returned HTTP #{response.status}")
90
+ end
91
+ if oversized?(response)
92
+ return failure(reason: "response from #{uri} is too large (#{content_length(response)} bytes)")
93
+ end
94
+
95
+ render(uri:, response:)
96
+ end
97
+
98
+ # Follow a redirect response's +Location+, enforcing the hop budget.
99
+ #
100
+ # @return [Protege::Result] the next hop's outcome, or a failure when there is no Location or the
101
+ # budget is exhausted
102
+ def follow_redirect(uri:, response:, redirects_left:)
103
+ location = response.headers['location'].to_s
104
+ return failure(reason: "redirect from #{uri} had no Location header") if location.empty?
105
+ return failure(reason: "too many redirects starting at #{uri}") if redirects_left <= 0
106
+
107
+ fetch(url: URI.join(uri.to_s, location).to_s, redirects_left: redirects_left - 1)
108
+ end
109
+
110
+ # Turn a successful response into a result, converting HTML to text and refusing binary content.
111
+ #
112
+ # @return [Protege::Result] success with the readable content, or a non-text failure
113
+ def render(uri:, response:)
114
+ type = content_type(response)
115
+ return failure(reason: "can't read non-text content (#{type || 'unknown type'})") unless textual?(type)
116
+
117
+ text = type == 'text/html' ? strip_html(response.body.to_s) : response.body.to_s
118
+ truncated = text.length > MAX_CONTENT_CHARS
119
+ text = text[0, MAX_CONTENT_CHARS] if truncated
120
+
121
+ success(url: uri.to_s, status: response.status, content_type: type, content: text, truncated:)
122
+ end
123
+
124
+ # Parse a string into an http(s) URI, or +nil+ when it is not a valid absolute web URL.
125
+ #
126
+ # @param url [String] the candidate URL
127
+ # @return [URI::HTTP, nil] the parsed URI (URI::HTTPS is a URI::HTTP), or nil
128
+ def parse_http_uri(url)
129
+ uri = URI.parse(url.to_s.strip)
130
+ uri if uri.is_a?(URI::HTTP) && uri.hostname
131
+ rescue URI::InvalidURIError
132
+ nil
133
+ end
134
+
135
+ # Whether a host must not be fetched: any of its resolved addresses (or the literal itself) is
136
+ # private, loopback, or link-local.
137
+ #
138
+ # @param host [String] the URI hostname (brackets already stripped by URI#hostname)
139
+ # @return [Boolean] true when the host should be refused
140
+ def blocked?(host)
141
+ addresses_for(host).any? { |ip| private_address?(ip) }
142
+ end
143
+
144
+ # The IP addresses to vet for a host: the literal itself when it is already an IP, otherwise every
145
+ # address it resolves to. An unresolvable host yields no addresses (the request then fails naturally
146
+ # at the transport layer).
147
+ #
148
+ # @param host [String] the hostname or IP literal
149
+ # @return [Array<String>] addresses to check
150
+ def addresses_for(host)
151
+ IPAddr.new(host)
152
+ [host]
153
+ rescue IPAddr::InvalidAddressError
154
+ Resolv.getaddresses(host)
155
+ end
156
+
157
+ # Whether an IP string falls in a range the agent must never reach.
158
+ #
159
+ # @param ip [String] an IP address
160
+ # @return [Boolean] true for private/loopback/link-local (or unparseable) addresses
161
+ def private_address?(ip)
162
+ addr = IPAddr.new(ip)
163
+ addr.loopback? || addr.private? || addr.link_local?
164
+ rescue IPAddr::InvalidAddressError
165
+ true
166
+ end
167
+
168
+ # The response's media type, lower-cased and without parameters (e.g. +text/html+), or nil.
169
+ #
170
+ # @param response [Faraday::Response] the HTTP response
171
+ # @return [String, nil] the bare content type
172
+ def content_type(response)
173
+ response.headers['content-type'].to_s.split(';').first&.strip&.downcase.presence
174
+ end
175
+
176
+ # Whether a media type is readable as text: text/*, JSON, XML, or a +json/+xml structured suffix.
177
+ #
178
+ # @param type [String, nil] the bare content type
179
+ # @return [Boolean] true when the body should be treated as text
180
+ def textual?(type)
181
+ return true if type.nil? # servers that omit the type are treated as text/plain
182
+
183
+ type.start_with?('text/') ||
184
+ %w[application/json application/xml application/xhtml+xml].include?(type) ||
185
+ type.end_with?('+json', '+xml')
186
+ end
187
+
188
+ # Whether the advertised +Content-Length+ exceeds the hard cap.
189
+ #
190
+ # @param response [Faraday::Response] the HTTP response
191
+ # @return [Boolean] true when too large to accept
192
+ def oversized?(response)
193
+ length = content_length(response)
194
+ length && length > MAX_BYTES
195
+ end
196
+
197
+ # The advertised content length, or nil when the header is absent.
198
+ #
199
+ # @param response [Faraday::Response] the HTTP response
200
+ # @return [Integer, nil] the byte count
201
+ def content_length(response)
202
+ response.headers['content-length']&.to_i
203
+ end
204
+
205
+ # Reduce an HTML document to readable text using the same tag-stripper the mail layer uses.
206
+ #
207
+ # @param html [String] the HTML body
208
+ # @return [String] the stripped, trimmed text
209
+ def strip_html(html)
210
+ ActionController::Base.helpers.strip_tags(html).strip
211
+ end
212
+
213
+ # Build (and memoize) the Faraday client used for every hop.
214
+ #
215
+ # @return [Faraday::Connection] the configured HTTP client
216
+ def connection
217
+ @connection ||= Faraday.new do |c|
218
+ c.headers['User-Agent'] = Protege.configuration.http_user_agent
219
+ c.options.open_timeout = OPEN_TIMEOUT
220
+ c.options.timeout = READ_TIMEOUT
221
+ end
222
+ end
223
+ end
224
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'nokogiri'
4
+
5
+ module Protege
6
+ # Built-in web-search tool — the way an agent finds pages on the open web. Subclasses
7
+ # +Protege::Tool+, so the harness publishes it in the LLM's tool catalog (id +:web_search+); when
8
+ # the model emits a +web_search+ call, the harness routes the arguments to +#use+.
9
+ #
10
+ # It is a keyless, best-effort search: it scrapes DuckDuckGo's HTML endpoint and parses the results
11
+ # page into a list of +{title, url, snippet}+ the agent can reason over and then +web_fetch+. Because
12
+ # it scrapes an unofficial endpoint, it can change or be rate-limited — that trade-off buys a search
13
+ # that needs no API key and is safe to expose to every persona. Richer or keyed search (Tavily,
14
+ # Perplexity, Brave, …) belongs in a host-app tool, keyed from the host's own config.
15
+ class WebSearchTool < Protege::Tool
16
+ # Result count when the caller does not specify a +limit+.
17
+ DEFAULT_LIMIT = 5
18
+
19
+ # Absolute ceiling on the result count, regardless of the requested +limit+.
20
+ MAX_LIMIT = 25
21
+
22
+ # DuckDuckGo's HTML results endpoint (no API key required).
23
+ ENDPOINT = 'https://html.duckduckgo.com/html/'
24
+
25
+ # Connection- and read-timeouts (seconds) for the HTTP request.
26
+ OPEN_TIMEOUT = 5
27
+ READ_TIMEOUT = 10
28
+
29
+ description <<~DESC.strip
30
+ Search the web and get back a list of matching pages (title, url, snippet). Use this to find
31
+ current information or sources, then use web_fetch to read a result's url in full.
32
+
33
+ Provide a query string. Optionally set limit to cap the number of results (default 5, maximum
34
+ 25). Results come from DuckDuckGo and are best-effort — refine the query rather than raising the
35
+ limit when results are off.
36
+ DESC
37
+
38
+ input_schema(
39
+ type: 'object',
40
+ additionalProperties: false,
41
+ required: %w[query],
42
+ properties: {
43
+ query: {
44
+ type: 'string',
45
+ description: 'The search query.'
46
+ },
47
+ limit: {
48
+ type: 'integer',
49
+ description: 'Maximum number of results to return. Defaults to 5; capped at 25.'
50
+ }
51
+ }
52
+ )
53
+
54
+ # Run the search and return the matching pages.
55
+ #
56
+ # @param context [Protege::Orchestrator::Context] the per-delivery tool context (unused)
57
+ # @param query [String] the search query supplied by the model
58
+ # @param limit [Integer, nil] optional result cap (defaults to {DEFAULT_LIMIT}, capped at {MAX_LIMIT})
59
+ # @return [Protege::Result] success carrying +query+, +count+, and +results+, or a failure (blank
60
+ # query, non-2xx, or transport error)
61
+ def use(context:, query:, limit: nil)
62
+ term = query.to_s.strip
63
+ return failure(reason: 'query cannot be empty') if term.empty?
64
+
65
+ response = connection.get(ENDPOINT) { |req| req.params['q'] = term }
66
+ return failure(reason: "web search returned HTTP #{response.status}") unless (200..299).cover?(response.status)
67
+
68
+ results = parse_results(response.body.to_s).first(clamp(limit))
69
+ success(query: term, count: results.size, results:)
70
+ rescue Faraday::Error => e
71
+ failure(reason: "web search failed: #{e.message}")
72
+ end
73
+
74
+ private
75
+
76
+ # Clamp the requested limit into 1..{MAX_LIMIT}, defaulting when none is given.
77
+ #
78
+ # @param limit [Integer, nil] the requested cap
79
+ # @return [Integer] the effective cap
80
+ def clamp(limit)
81
+ (limit || DEFAULT_LIMIT).to_i.clamp(1, MAX_LIMIT)
82
+ end
83
+
84
+ # Parse a DuckDuckGo HTML results page into result hashes, skipping ad rows and rows without a
85
+ # usable link.
86
+ #
87
+ # @param html [String] the results-page HTML
88
+ # @return [Array<Hash>] +{title:, url:, snippet:}+ for each organic result, in page order
89
+ def parse_results(html)
90
+ Nokogiri::HTML(html).css('div.result').filter_map do |node|
91
+ next if node['class'].to_s.include?('result--ad')
92
+
93
+ link = node.at_css('a.result__a')
94
+ url = link && result_url(link['href'])
95
+ next unless url
96
+
97
+ { title: link.text.strip, url:, snippet: node.at_css('.result__snippet')&.text&.strip }
98
+ end
99
+ end
100
+
101
+ # Extract the real destination from a DuckDuckGo result href, which wraps external links as
102
+ # +//duckduckgo.com/l/?uddg=<url-encoded target>&rut=…+. Protocol-relative or already-direct hrefs
103
+ # are normalized/returned as-is.
104
+ #
105
+ # @param href [String, nil] the anchor's href
106
+ # @return [String, nil] the destination URL, or nil when there is nothing usable
107
+ def result_url(href)
108
+ href = href.to_s
109
+ return nil if href.empty?
110
+
111
+ if href.include?('uddg=')
112
+ URI.decode_www_form(href.split('?', 2).last.to_s).to_h['uddg']
113
+ elsif href.start_with?('//')
114
+ "https:#{href}"
115
+ else
116
+ href
117
+ end
118
+ end
119
+
120
+ # Build (and memoize) the Faraday client for the search request.
121
+ #
122
+ # @return [Faraday::Connection] the configured HTTP client
123
+ def connection
124
+ @connection ||= Faraday.new do |c|
125
+ c.headers['User-Agent'] = Protege.configuration.http_user_agent
126
+ c.options.open_timeout = OPEN_TIMEOUT
127
+ c.options.timeout = READ_TIMEOUT
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,19 @@
1
+ doctype html
2
+
3
+ html lang="en" class="h-full"
4
+ head
5
+ title Protege
6
+ meta name="viewport" content="width=device-width,initial-scale=1"
7
+
8
+ = csrf_meta_tags
9
+ = csp_meta_tag
10
+
11
+ = stylesheet_link_tag "protege", data: { "turbo-track": "reload" }
12
+
13
+ = javascript_importmap_tags
14
+ = javascript_include_tag(*js_files)
15
+
16
+ = app_body do
17
+ = yield
18
+
19
+ = render "protege/shared/hotkeys"
@@ -0,0 +1,6 @@
1
+ - if @access_rule.persisted?
2
+ = turbo_stream.append "access_rules", partial: "protege/personas/access_rule", locals: { access_rule: @access_rule }
3
+ = turbo_stream.remove "access_rules_empty"
4
+ = turbo_stream.replace "access_rule_form", partial: "protege/personas/access_rule_form", locals: { persona: @persona, access_rule: @persona.access_rules.build }
5
+ - else
6
+ = turbo_stream.replace "access_rule_form", partial: "protege/personas/access_rule_form", locals: { persona: @persona, access_rule: @access_rule }
@@ -0,0 +1,4 @@
1
+ = sidebar_item(email_domain.domain, email_domain_path(email_domain),
2
+ subtitle: "DKIM: #{email_domain.dkim_selector}._domainkey",
3
+ active: local_assigns[:active],
4
+ id: dom_id(email_domain))
@@ -0,0 +1,10 @@
1
+ = form_with url: (email_domain.new_record? ? email_domains_path : email_domain_path(email_domain)), model: email_domain do |f|
2
+ = form_errors(email_domain)
3
+
4
+ .space-y-4
5
+ = form_field(f, :domain, placeholder: "mail.example.com")
6
+
7
+ = form_field(f, :dkim_selector, label: "DKIM selector", placeholder: "default", hint: "Used as the prefix for the DKIM TXT record name.")
8
+
9
+ .pt-2
10
+ = button(email_domain.new_record? ? "Add domain" : "Save changes", type: :submit)
@@ -0,0 +1,11 @@
1
+ - pagy, domains = paginate(all_email_domains)
2
+
3
+ = sidebar_content do
4
+ = sidebar_header("Domains (#{pagy.count})", new_path: new_email_domain_path)
5
+
6
+ = sidebar_stream("protege:email_domains", pagy: pagy)
7
+
8
+ = sidebar_body(domains, empty: "No domains yet", id: "email_domains_list") do |email_domain|
9
+ = render "email_domain_sidebar_item", email_domain: email_domain, active: local_assigns[:current] == email_domain
10
+
11
+ = sidebar_pagination(pagy)
@@ -0,0 +1,6 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar"
3
+
4
+ = page_empty do
5
+ | Select a domain from the sidebar, or &nbsp;
6
+ = ui_link("add a new one.", new_email_domain_path)
@@ -0,0 +1,7 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar"
3
+
4
+ = page_container do
5
+ = page_header("Add email domain", "A DKIM keypair is generated automatically. DNS records are shown after saving.")
6
+
7
+ = card { render "form", email_domain: @email_domain }
@@ -0,0 +1,36 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar", current: @email_domain
3
+
4
+ = page_container do
5
+ = page_header(@email_domain.domain, "Configure these DNS records at your registrar or DNS provider.") do
6
+ = button("Remove", type: "button", variant: :danger, onclick: show_dialog("remove-domain"))
7
+
8
+ .space-y-4
9
+ = info_card("MX record", { "Name" => "#{@email_domain.domain}.", "Value" => @email_domain.mx_record })
10
+
11
+ = info_card("SPF record (TXT)", { "Name" => "#{@email_domain.domain}.", "Value" => @email_domain.spf_txt_record })
12
+
13
+ / DKIM signing is ours only when we self-host the MTA; an email provider signs with its own key, so we
14
+ / point the operator at the provider's records rather than publishing this domain's key.
15
+ - if mail_self_hosted?
16
+ = info_card("DKIM record (TXT)", { "Name" => "#{@email_domain.dkim_dns_name}.", "Value" => @email_domain.dkim_txt_record })
17
+ - else
18
+ = info_card("DKIM record (TXT)", { "Note" => "Managed by your email provider — publish the DKIM records it gives you." })
19
+
20
+ = info_card("DMARC record (TXT)", { "Name" => "_dmarc.#{@email_domain.domain}.", "Value" => @email_domain.dmarc_txt_record })
21
+
22
+ / Self-hosting only: saved domains are pushed to the MTA automatically; this is the manual re-push.
23
+ - if mail_self_hosted?
24
+ = card do
25
+ = heading_2("Mail server")
26
+ p.mt-1.text-sm style="color: var(--text-muted)"
27
+ | Saved domains are pushed to your self-hosted mail server automatically. Use Sync now to re-push — for example after the mail server restarts.
28
+ .mt-3
29
+ = action_button("Sync now", email_domain_mta_domains_sync_path(@email_domain))
30
+
31
+ = render("protege/shared/confirm_dialog",
32
+ id: "remove-domain",
33
+ title: "Remove #{@email_domain.domain}?",
34
+ message: "Removing this domain only deletes it from Protege. It does not affect your DNS records. If this domain is in use by a persona, you will need to de-register the MX, SPF, DKIM, and DMARC entries with your DNS provider separately.",
35
+ action_url: email_domain_path(@email_domain),
36
+ action_text: "Remove domain")
@@ -0,0 +1,26 @@
1
+ = page_container do
2
+ = page_header('About Protege')
3
+
4
+ div class="space-y-4 text-sm leading-relaxed mb-10"
5
+ p Protege is an email-native AI agent toolkit. Your personas receive email, process it through an inference pipeline, and respond autonomously using the tools you configure. Every conversation — whether from an external email client or this console — flows through the same harness.
6
+
7
+ p To get started, configure at least one email domain with valid DNS records, create a persona with a routable email address, and set up your provider credentials. Once a persona is active and your MTA is running, it will process inbound email automatically.
8
+
9
+ p Protege is a toolkit for developers, not an out-of-the-box solution. Every part of the pipeline — providers, resolvers, tools, hooks — is an extension surface you implement. The #{ui_link("documentation", "https://docs.protege.bot", target: "_blank")} covers each surface in detail with examples and guides.
10
+
11
+ div class="mt-10"
12
+ div class="mb-4"
13
+ = heading_2("Extension surfaces")
14
+
15
+ = grid_container(cols: 2) do
16
+ = grid_card("Personas", "The agent identity that receives and responds to email. Each persona has its own address, resolver chain, and display name.", href: "https://docs.protege.bot/personas")
17
+
18
+ = grid_card("Providers", "LLM provider integration — OpenRouter, Anthropic, or any OpenAI-compatible endpoint.", href: "https://docs.protege.bot/providers")
19
+
20
+ = grid_card("Resolvers", "Build inference context before each generation — system prompts, conversation history, or custom data.", href: "https://docs.protege.bot/resolvers")
21
+
22
+ = grid_card("Tools", "Capabilities exposed to the LLM — send email, query APIs, or anything you implement.", href: "https://docs.protege.bot/tools")
23
+
24
+ = grid_card("Hooks", "Subscribe to lifecycle events — inference, tool calls, failures — for logging, metrics, or side effects.", href: "https://docs.protege.bot/hooks")
25
+
26
+ = grid_card("Gateway", "Inbound and outbound email transport — SMTP, console messages, threading, and persistence.", href: "https://docs.protege.bot/gateway")
@@ -0,0 +1,19 @@
1
+ = turbo_frame_tag dom_id(message) do
2
+ div class="py-4" style="border-top: 1px solid var(--border)"
3
+ div class="flex items-start justify-between text-sm"
4
+ = message_sender(message)
5
+ div class="flex items-center gap-3"
6
+ = message_timestamp(message)
7
+ = dropdown_menu do
8
+ = dropdown_item("Delete", message_path(message), method: :delete, destructive: true)
9
+
10
+ = message_recipients(message)
11
+
12
+ = message_subject(message)
13
+
14
+ = message_headers(message)
15
+
16
+ .mt-3
17
+ = body_text(message.text_body.to_s.strip)
18
+
19
+ = message_attachments(message)
@@ -0,0 +1,13 @@
1
+ - pagy, messages = paginate(results)
2
+ - empty_message = search.active? ? "No emails match your search" : "Enter search terms to find emails"
3
+
4
+ = sidebar_content(frame: "search_results") do
5
+ = sidebar_header("Results (#{pagy.count})")
6
+
7
+ = sidebar_body(messages, empty: empty_message) do |message|
8
+ = sidebar_item(search_result_title(message), thread_path(message.email_thread, anchor: dom_id(message)),
9
+ detail: search_result_date(message),
10
+ subtitle: message.from_address,
11
+ data: { turbo_frame: "_top" })
12
+
13
+ = sidebar_pagination(pagy)
@@ -0,0 +1,20 @@
1
+ - content_for :sidebar do
2
+ = render "sidebar", search: @search, results: @messages
3
+
4
+ = page_container do
5
+ = page_header("Search emails", "Find emails by sender, recipients, subject, body, or attachment. Matches appear in the sidebar.")
6
+
7
+ = card do
8
+ = form_with url: messages_path, method: :get, data: { turbo_frame: "search_results" }, class: "space-y-5" do |f|
9
+ = form_field(f, :from, label: "From", placeholder: "sender address", value: params[:from], onblur: "this.form.requestSubmit()")
10
+
11
+ = form_field(f, :recipients, label: "Recipients", placeholder: "to, cc, or bcc address", value: params[:recipients], onblur: "this.form.requestSubmit()")
12
+
13
+ = form_field(f, :subject, label: "Subject", placeholder: "subject contains…", value: params[:subject], onblur: "this.form.requestSubmit()")
14
+
15
+ = form_field(f, :body, label: "Body", placeholder: "body contains…", value: params[:body], onblur: "this.form.requestSubmit()")
16
+
17
+ = form_field(f, :attachments, label: "Attachments", placeholder: "filename contains…", value: params[:attachments], onblur: "this.form.requestSubmit()")
18
+
19
+ .pt-2
20
+ = button("Search", type: :submit)
@@ -0,0 +1,7 @@
1
+ tr id=dom_id(access_rule) style="border-top: 1px solid var(--border)"
2
+ td class="py-3 pr-4 align-middle"
3
+ = badge(access_rule.kind, access_rule.allow? ? :green : :red)
4
+ td class="py-3 pr-4 align-middle"
5
+ code class="font-mono break-all" = access_rule.pattern
6
+ td class="py-3 align-middle"
7
+ = destructive_link("Remove", persona_access_rule_path(access_rule.persona, access_rule), confirm: "Remove this rule?")
@@ -0,0 +1,8 @@
1
+ #access_rule_form
2
+ = form_with model: access_rule, url: persona_access_rules_path(persona), scope: :access_rule, class: "space-y-3" do |f|
3
+ = form_errors(access_rule)
4
+ .flex.items-end.gap-3
5
+ = form_select(f, :kind, Protege::AccessRule.kinds.keys.map { |k| [k.capitalize, k] }, label: "Kind")
6
+ .flex-1
7
+ = form_field(f, :pattern, label: "Pattern", placeholder: "*@company.co")
8
+ = button("Add rule", type: :submit, class: "h-9")
@@ -0,0 +1,19 @@
1
+ / Inbound access rules for a persona — the dashboard-editable, narrowing layer of the access
2
+ / guardrail. Lists existing rules with a remove control and an add form. Create/destroy update the
3
+ / table in place via Turbo Streams (the persona page survives), falling back to a redirect for non-Turbo.
4
+
5
+ = card do
6
+ .space-y-4
7
+ = heading_2("Inbound access rules")
8
+ = hint("Allow or deny senders for this agent. Rules only narrow the global policy — they can never widen it.")
9
+
10
+ = render "access_rule_form", persona: persona, access_rule: persona.access_rules.build
11
+
12
+ table class="w-full text-sm"
13
+ tbody id="access_rules"
14
+ - if persona.access_rules.any?
15
+ = render partial: "access_rule", collection: persona.access_rules.order(:kind, :pattern), as: :access_rule
16
+ - else
17
+ tr id="access_rules_empty"
18
+ td colspan="3" class="py-6 text-center text-sm" style="color: var(--text-faint)"
19
+ | No rules — every sender permitted by the global policy can reach this agent.
@@ -0,0 +1,15 @@
1
+ / The persona show page's action bar — Edit, Archive/Unarchive, and permanent Delete — plus an "Archived"
2
+ / badge when soft-removed. Wrapped in a dom_id so Archive/Unarchive can replace it in place over Turbo
3
+ / (see ArchivesController). The Delete button opens the confirm dialog rendered by the show template.
4
+ div id=dom_id(persona, :actions) class="flex items-center gap-3"
5
+ - if persona.archived?
6
+ = badge("Archived")
7
+
8
+ = ui_link("Edit", edit_persona_path(persona))
9
+
10
+ - if persona.archived?
11
+ = action_button("Unarchive", persona_archive_path(persona), method: :delete)
12
+ - else
13
+ = action_button("Archive", persona_archive_path(persona), method: :post, variant: :secondary)
14
+
15
+ = button("Delete", type: "button", variant: :danger, onclick: show_dialog("delete-persona"))
@@ -0,0 +1,29 @@
1
+ - url = persona.new_record? ? personas_path : persona_path(persona)
2
+ - method = persona.new_record? ? :post : :patch
3
+
4
+ = form_with url:, method:, scope: :persona, class: "space-y-5" do |f|
5
+ = form_errors(persona)
6
+
7
+ = form_field(f, :name, placeholder: "e.g. alice")
8
+
9
+ = form_email_address(persona, available_email_domains)
10
+
11
+ - unless persona.new_record?
12
+ = hint("Changing the address only affects new mail — messages to the old address will no longer reach this persona. Existing threads and history are unaffected.")
13
+
14
+ - if persona.new_record?
15
+ = form_select(f, :type, available_persona_types.map { |k| [k.display_name, k.name] }, label: "Type")
16
+
17
+ = form_text_area(f, :instructions, \
18
+ label: "Instructions", \
19
+ rows: 10, \
20
+ hint: "The agent's system prompt — who it is and how it behaves. Only sent to the model when a resolver (such as LoadText) includes it in the agent's context.")
21
+
22
+ - unless persona.new_record?
23
+ - tool_id = ->(tool) { tool.id.to_s }
24
+ = form_checklist(f, :disabled_tool_ids, persona_tool_options(persona), label: "Blocked Tools", value: tool_id, text: tool_id, hint: "Disabled tools aren't offered to the agent; everything else stays available.")
25
+
26
+ .flex.items-center.gap-3.pt-2
27
+ = button(persona.new_record? ? "Create persona" : "Save changes", type: :submit)
28
+
29
+ = ui_link("Cancel", persona.new_record? ? personas_path : persona_path(persona))
@@ -0,0 +1,5 @@
1
+ = sidebar_item(persona_label(persona), persona_path(persona),
2
+ detail: persona_status(persona),
3
+ subtitle: persona.email_address,
4
+ active: local_assigns[:active],
5
+ id: dom_id(persona))
@@ -0,0 +1,11 @@
1
+ - pagy, personas = paginate(all_personas)
2
+
3
+ = sidebar_content do
4
+ = sidebar_header("Personas (#{pagy.count})", new_path: new_persona_path)
5
+
6
+ = sidebar_stream("protege:personas", pagy: pagy)
7
+
8
+ = sidebar_body(personas, empty: "No personas yet", id: "personas_list") do |persona|
9
+ = render "persona_sidebar_item", persona: persona, active: local_assigns[:current] == persona
10
+
11
+ = sidebar_pagination(pagy)