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.
- checksums.yaml +7 -0
- data/README.md +24 -0
- data/Rakefile +12 -0
- data/app/assets/builds/protege.css +1128 -0
- data/app/assets/javascripts/protege/attachment_picker.js +163 -0
- data/app/assets/javascripts/protege/copy.js +80 -0
- data/app/assets/javascripts/protege/forms.js +28 -0
- data/app/assets/javascripts/protege/keys.js +212 -0
- data/app/assets/javascripts/protege/resize.js +149 -0
- data/app/assets/javascripts/protege/theme.js +25 -0
- data/app/assets/stylesheets/protege/application.css +15 -0
- data/app/assets/tailwind/application.css +137 -0
- data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
- data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
- data/app/controllers/protege/access_rules_controller.rb +72 -0
- data/app/controllers/protege/application_controller.rb +18 -0
- data/app/controllers/protege/archives_controller.rb +52 -0
- data/app/controllers/protege/email_domains_controller.rb +72 -0
- data/app/controllers/protege/home_controller.rb +15 -0
- data/app/controllers/protege/messages_controller.rb +46 -0
- data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
- data/app/controllers/protege/personas_controller.rb +118 -0
- data/app/controllers/protege/replies_controller.rb +64 -0
- data/app/controllers/protege/responsibilities_controller.rb +82 -0
- data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
- data/app/controllers/protege/threads_controller.rb +75 -0
- data/app/controllers/protege/traces/searches_controller.rb +19 -0
- data/app/controllers/protege/traces_controller.rb +52 -0
- data/app/helpers/protege/application_helper.rb +30 -0
- data/app/helpers/protege/components/badges_helper.rb +59 -0
- data/app/helpers/protege/components/buttons_helper.rb +141 -0
- data/app/helpers/protege/components/cards_helper.rb +48 -0
- data/app/helpers/protege/components/components_helper.rb +30 -0
- data/app/helpers/protege/components/dialogs_helper.rb +36 -0
- data/app/helpers/protege/components/forms_helper.rb +223 -0
- data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
- data/app/helpers/protege/components/icons_helper.rb +41 -0
- data/app/helpers/protege/components/introspection_helper.rb +162 -0
- data/app/helpers/protege/components/layout_helper.rb +169 -0
- data/app/helpers/protege/components/menus_helper.rb +68 -0
- data/app/helpers/protege/components/pagination_helper.rb +100 -0
- data/app/helpers/protege/components/sidebar_helper.rb +211 -0
- data/app/helpers/protege/components/tables_helper.rb +59 -0
- data/app/helpers/protege/components/typography_helper.rb +64 -0
- data/app/helpers/protege/email_domains_helper.rb +22 -0
- data/app/helpers/protege/messages_helper.rb +152 -0
- data/app/helpers/protege/personas_helper.rb +90 -0
- data/app/helpers/protege/responsibilities_helper.rb +63 -0
- data/app/helpers/protege/threads_helper.rb +118 -0
- data/app/helpers/protege/trace_helper.rb +22 -0
- data/app/hooks/protege/event_logger_hook.rb +109 -0
- data/app/jobs/protege/application_job.rb +41 -0
- data/app/jobs/protege/console_inference_job.rb +57 -0
- data/app/jobs/protege/inference_job.rb +60 -0
- data/app/jobs/protege/mail_provisioning_job.rb +21 -0
- data/app/jobs/protege/responsibility_job.rb +70 -0
- data/app/jobs/protege/responsibility_tick_job.rb +19 -0
- data/app/mailboxes/protege/agent_mailbox.rb +123 -0
- data/app/mailers/protege/application_mailer.rb +65 -0
- data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
- data/app/models/concerns/protege/broadcastable_message.rb +47 -0
- data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
- data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
- data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
- data/app/models/concerns/protege/tool_scoped.rb +88 -0
- data/app/models/protege/access_rule.rb +50 -0
- data/app/models/protege/application_record.rb +18 -0
- data/app/models/protege/current.rb +23 -0
- data/app/models/protege/email_domain.rb +136 -0
- data/app/models/protege/email_thread.rb +81 -0
- data/app/models/protege/message.rb +401 -0
- data/app/models/protege/persona.rb +229 -0
- data/app/models/protege/responsibility.rb +98 -0
- data/app/models/protege/responsibility_run.rb +68 -0
- data/app/models/protege/tool_use.rb +115 -0
- data/app/models/protege/trace.rb +71 -0
- data/app/providers/protege/open_router_provider.rb +317 -0
- data/app/resolvers/README.md +255 -0
- data/app/resolvers/protege/load_file_resolver.rb +86 -0
- data/app/resolvers/protege/load_record_resolver.rb +80 -0
- data/app/resolvers/protege/load_text_resolver.rb +40 -0
- data/app/resolvers/protege/thread_history_resolver.rb +116 -0
- data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
- data/app/services/protege/message_search.rb +114 -0
- data/app/services/protege/trace_search.rb +102 -0
- data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
- data/app/tools/protege/create_file_tool.rb +109 -0
- data/app/tools/protege/read_attachment_tool.rb +101 -0
- data/app/tools/protege/search_emails_tool.rb +129 -0
- data/app/tools/protege/send_email_tool.rb +240 -0
- data/app/tools/protege/web_fetch_tool.rb +224 -0
- data/app/tools/protege/web_search_tool.rb +131 -0
- data/app/views/layouts/protege/application.html.slim +19 -0
- data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
- data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
- data/app/views/protege/email_domains/_form.html.slim +10 -0
- data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
- data/app/views/protege/email_domains/index.html.slim +6 -0
- data/app/views/protege/email_domains/new.html.slim +7 -0
- data/app/views/protege/email_domains/show.html.slim +36 -0
- data/app/views/protege/home/show.html.slim +26 -0
- data/app/views/protege/messages/_message.html.slim +19 -0
- data/app/views/protege/messages/_sidebar.html.slim +13 -0
- data/app/views/protege/messages/index.html.slim +20 -0
- data/app/views/protege/personas/_access_rule.html.slim +7 -0
- data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
- data/app/views/protege/personas/_access_rules.html.slim +19 -0
- data/app/views/protege/personas/_actions.html.slim +15 -0
- data/app/views/protege/personas/_form.html.slim +29 -0
- data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
- data/app/views/protege/personas/_sidebar.html.slim +11 -0
- data/app/views/protege/personas/edit.html.slim +7 -0
- data/app/views/protege/personas/index.html.slim +6 -0
- data/app/views/protege/personas/new.html.slim +7 -0
- data/app/views/protege/personas/show.html.slim +37 -0
- data/app/views/protege/responsibilities/_form.html.slim +21 -0
- data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
- data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
- data/app/views/protege/responsibilities/edit.html.slim +7 -0
- data/app/views/protege/responsibilities/index.html.slim +6 -0
- data/app/views/protege/responsibilities/new.html.slim +7 -0
- data/app/views/protege/responsibilities/show.html.slim +22 -0
- data/app/views/protege/responsibility_runs/show.html.slim +13 -0
- data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
- data/app/views/protege/shared/_header.html.slim +14 -0
- data/app/views/protege/shared/_hotkeys.html.slim +43 -0
- data/app/views/protege/threads/_composer_footer.html.slim +15 -0
- data/app/views/protege/threads/_introspection.html.slim +7 -0
- data/app/views/protege/threads/_reasoning.html.slim +4 -0
- data/app/views/protege/threads/_reply_form.html.slim +7 -0
- data/app/views/protege/threads/_run_marker.html.slim +3 -0
- data/app/views/protege/threads/_sidebar.html.slim +15 -0
- data/app/views/protege/threads/_thread.html.slim +6 -0
- data/app/views/protege/threads/_tool_call.html.slim +8 -0
- data/app/views/protege/threads/index.html.slim +6 -0
- data/app/views/protege/threads/new.html.slim +15 -0
- data/app/views/protege/threads/show.html.slim +25 -0
- data/app/views/protege/traces/_sidebar.html.slim +15 -0
- data/app/views/protege/traces/index.html.slim +11 -0
- data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
- data/app/views/protege/traces/searches/show.html.slim +22 -0
- data/app/views/protege/traces/show.html.slim +28 -0
- data/config/routes.rb +33 -0
- data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
- data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
- data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
- data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
- data/lib/generators/protege/extension_naming.rb +38 -0
- data/lib/generators/protege/hook/hook_generator.rb +33 -0
- data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
- data/lib/generators/protege/install/install_generator.rb +170 -0
- data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
- data/lib/generators/protege/persona/persona_generator.rb +35 -0
- data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
- data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
- data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
- data/lib/generators/protege/provider/provider_generator.rb +44 -0
- data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
- data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
- data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
- data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
- data/lib/generators/protege/tool/tool_generator.rb +34 -0
- data/lib/protege/configuration.rb +171 -0
- data/lib/protege/engine.rb +103 -0
- data/lib/protege/errors/bad_request_error.rb +8 -0
- data/lib/protege/errors/contract_violation_error.rb +8 -0
- data/lib/protege/errors/error.rb +30 -0
- data/lib/protege/errors/generic_error.rb +7 -0
- data/lib/protege/errors/internal_error.rb +8 -0
- data/lib/protege/errors/invalid_model_error.rb +8 -0
- data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
- data/lib/protege/errors/rate_limited_error.rb +8 -0
- data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
- data/lib/protege/errors/response_parse_failed_error.rb +8 -0
- data/lib/protege/errors/timeout_error.rb +8 -0
- data/lib/protege/errors/tool_not_available_error.rb +12 -0
- data/lib/protege/errors/tool_not_found_error.rb +9 -0
- data/lib/protege/errors/unauthorized_error.rb +8 -0
- data/lib/protege/errors/unavailable_error.rb +8 -0
- data/lib/protege/errors/unsupported_capability_error.rb +8 -0
- data/lib/protege/events/event.rb +98 -0
- data/lib/protege/events/inference_chunk_event.rb +17 -0
- data/lib/protege/events/inference_completed_event.rb +12 -0
- data/lib/protege/events/inference_failed_event.rb +12 -0
- data/lib/protege/events/inference_generated_event.rb +19 -0
- data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
- data/lib/protege/events/inference_started_event.rb +11 -0
- data/lib/protege/events/loop_run_completed_event.rb +11 -0
- data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
- data/lib/protege/events/loop_run_failed_event.rb +12 -0
- data/lib/protege/events/loop_run_started_event.rb +11 -0
- data/lib/protege/events/tool_call_completed_event.rb +12 -0
- data/lib/protege/events/tool_call_failed_event.rb +12 -0
- data/lib/protege/events/tool_call_started_event.rb +11 -0
- data/lib/protege/events/tool_calls_received_event.rb +11 -0
- data/lib/protege/extensions/hook.rb +13 -0
- data/lib/protege/extensions/hook_mixin.rb +90 -0
- data/lib/protege/extensions/provider.rb +12 -0
- data/lib/protege/extensions/provider_mixin.rb +124 -0
- data/lib/protege/extensions/resolver.rb +12 -0
- data/lib/protege/extensions/resolver_mixin.rb +58 -0
- data/lib/protege/extensions/tool.rb +12 -0
- data/lib/protege/extensions/tool_mixin.rb +217 -0
- data/lib/protege/gateway/access_control.rb +66 -0
- data/lib/protege/gateway/access_policy.rb +94 -0
- data/lib/protege/gateway/attachment_policy.rb +115 -0
- data/lib/protege/gateway/mail/address.rb +145 -0
- data/lib/protege/gateway/mail/attachment.rb +17 -0
- data/lib/protege/gateway/mail/message_id.rb +104 -0
- data/lib/protege/gateway/mail/outbound.rb +181 -0
- data/lib/protege/gateway/mail/references.rb +107 -0
- data/lib/protege/gateway.rb +398 -0
- data/lib/protege/inference/provider/request.rb +110 -0
- data/lib/protege/inference/provider/response.rb +50 -0
- data/lib/protege/inference/provider/tool_call.rb +52 -0
- data/lib/protege/inference.rb +121 -0
- data/lib/protege/loop/schedule.rb +66 -0
- data/lib/protege/loop/scheduler.rb +42 -0
- data/lib/protege/loop.rb +37 -0
- data/lib/protege/orchestrator/context.rb +65 -0
- data/lib/protege/orchestrator/harness.rb +305 -0
- data/lib/protege/orchestrator/reply_context.rb +21 -0
- data/lib/protege/orchestrator/reply_harness.rb +68 -0
- data/lib/protege/orchestrator/resolver_chain.rb +93 -0
- data/lib/protege/orchestrator/responsibility_context.rb +22 -0
- data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
- data/lib/protege/orchestrator.rb +37 -0
- data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
- data/lib/protege/subscribers/tracing.rb +65 -0
- data/lib/protege/types/document_part.rb +13 -0
- data/lib/protege/types/image_part.rb +11 -0
- data/lib/protege/types/model_message.rb +40 -0
- data/lib/protege/types/result.rb +113 -0
- data/lib/protege/types/stored_file.rb +127 -0
- data/lib/protege/types/text_part.rb +8 -0
- data/lib/protege/types/tool_result.rb +16 -0
- data/lib/protege/version.rb +6 -0
- data/lib/protege.rb +47 -0
- data/lib/tasks/protege_tasks.rake +16 -0
- 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,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,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,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)
|