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,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Built-in tool that turns content the agent authored into a stored file it can later attach to an
|
|
5
|
+
# email. Subclasses +Protege::Tool+, so the harness publishes it in the LLM's tool catalog (id
|
|
6
|
+
# +:create_file+); when the model calls it, the harness routes the arguments to +#use+.
|
|
7
|
+
#
|
|
8
|
+
# The file is written to Active Storage as a standalone +ActiveStorage::Blob+ (no owner record) and
|
|
9
|
+
# the tool returns its +blob_id+. That id is the currency the +send_email+ tool accepts, so the flow
|
|
10
|
+
# is: author content here, get a blob id, then reference it from +send_email+'s +attachments+. Keeping
|
|
11
|
+
# storage and sending as two steps lets the agent create several files, review them via
|
|
12
|
+
# +read_attachment+, and attach any subset.
|
|
13
|
+
#
|
|
14
|
+
# The LLM only ever supplies *text*; the engine never asks it for binary bytes. The +format+ enum is
|
|
15
|
+
# therefore the whole capability surface — each maps to a content type the authored text is stored
|
|
16
|
+
# under. A format that needs a real transform (e.g. HTML->PDF) is a later addition; today every
|
|
17
|
+
# format stores the text as-is.
|
|
18
|
+
class CreateFileTool < Protege::Tool
|
|
19
|
+
# Supported formats mapped to the content type the authored text is stored under. The keys are the
|
|
20
|
+
# +format+ enum; the format name is also the file extension. New text formats are one row here.
|
|
21
|
+
FORMATS = {
|
|
22
|
+
csv: 'text/csv',
|
|
23
|
+
txt: 'text/plain',
|
|
24
|
+
md: 'text/markdown',
|
|
25
|
+
html: 'text/html',
|
|
26
|
+
json: 'application/json',
|
|
27
|
+
svg: 'image/svg+xml'
|
|
28
|
+
}.freeze
|
|
29
|
+
|
|
30
|
+
description <<~DESC.strip
|
|
31
|
+
Create a file from content you write, and get back a blob id you can attach to an email.
|
|
32
|
+
|
|
33
|
+
Use this when the user wants a document — a CSV of data, an HTML or Markdown report, a JSON
|
|
34
|
+
export, an SVG chart. Write the full file content yourself in the chosen format; it is stored as
|
|
35
|
+
a file and you receive its id. To send it, call send_email with that id in attachments (you can
|
|
36
|
+
create several files and attach any of them). You can also review a file you made with
|
|
37
|
+
read_attachment before sending.
|
|
38
|
+
|
|
39
|
+
Supported formats: csv, txt, md, html, json, svg. Provide the content as text — for csv write the
|
|
40
|
+
rows, for html/svg write the markup, for json write the JSON.
|
|
41
|
+
DESC
|
|
42
|
+
|
|
43
|
+
input_schema(
|
|
44
|
+
type: 'object',
|
|
45
|
+
additionalProperties: false,
|
|
46
|
+
required: %w[format filename content],
|
|
47
|
+
properties: {
|
|
48
|
+
format: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
enum: FORMATS.keys.map(&:to_s),
|
|
51
|
+
description: 'The file format. Determines the content type and extension.'
|
|
52
|
+
},
|
|
53
|
+
filename: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
description: 'The base filename (the correct extension is applied automatically).'
|
|
56
|
+
},
|
|
57
|
+
content: {
|
|
58
|
+
type: 'string',
|
|
59
|
+
description: 'The complete file content you authored, as text in the chosen format.'
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Render the authored content to a stored blob and return its id.
|
|
65
|
+
#
|
|
66
|
+
# @param context [Protege::Orchestrator::Context] the per-run context (unused — a blob has no owner)
|
|
67
|
+
# @param format [String] one of {FORMATS}'s keys
|
|
68
|
+
# @param filename [String] the base filename; normalized to carry the format's extension
|
|
69
|
+
# @param content [String] the file content the model authored
|
|
70
|
+
# @return [Protege::Result] success carrying +blob_id+ and metadata, or a failure (unknown format
|
|
71
|
+
# or a size-limit breach)
|
|
72
|
+
def use(context:, format:, filename:, content:)
|
|
73
|
+
content_type = FORMATS[format.to_sym]
|
|
74
|
+
return failure(reason: "unknown format #{format.inspect}") unless content_type
|
|
75
|
+
|
|
76
|
+
bytes = content.to_s.b
|
|
77
|
+
violation = Protege.configuration.attachment_policy.violation(byte_sizes: [bytes.bytesize])
|
|
78
|
+
return failure(reason: violation) if violation
|
|
79
|
+
|
|
80
|
+
blob = ActiveStorage::Blob.create_and_upload!(
|
|
81
|
+
io: StringIO.new(bytes),
|
|
82
|
+
filename: with_extension(filename, format),
|
|
83
|
+
content_type:
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
success(
|
|
87
|
+
blob_id: blob.id,
|
|
88
|
+
filename: blob.filename.to_s,
|
|
89
|
+
byte_size: blob.byte_size,
|
|
90
|
+
content_type:
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
# Normalize +filename+ to carry the format's extension, replacing a wrong or missing one.
|
|
97
|
+
#
|
|
98
|
+
# @param filename [String] the base filename supplied by the model
|
|
99
|
+
# @param format [String, Symbol] the chosen format (its name is the extension)
|
|
100
|
+
# @return [String] the filename ending in +.<format>+
|
|
101
|
+
def with_extension(filename, format)
|
|
102
|
+
base = File.basename(filename.to_s.strip)
|
|
103
|
+
base = base.delete_suffix(File.extname(base)) unless File.extname(base).empty?
|
|
104
|
+
base = 'file' if base.empty?
|
|
105
|
+
|
|
106
|
+
"#{base}.#{format}"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Built-in tool that reads a stored file by its blob id. Subclasses +Protege::Tool+, so the harness
|
|
5
|
+
# publishes it in the LLM's tool catalog (id +:read_attachment+).
|
|
6
|
+
#
|
|
7
|
+
# The agent learns a file's blob id from the +ThreadHistoryResolver+/inbound note ("…, id: 123") or
|
|
8
|
+
# from +create_file+. Resolution is by blob id (see +Protege::StoredFile.find+); persona scoping is
|
|
9
|
+
# deferred to a later pass (see the blob-currency spec §9), so any known blob id currently resolves.
|
|
10
|
+
# Behaviour by type:
|
|
11
|
+
#
|
|
12
|
+
# - **text-like** (text/*, json, xml, csv) → the text content is returned in the result.
|
|
13
|
+
# - **image / PDF** → returned as a "review" marker carrying only the blob id (no bytes); the harness
|
|
14
|
+
# rebuilds it as a vision part and injects it on the next user turn, because OpenAI-schema tool
|
|
15
|
+
# results can't carry images. The model then sees the actual image/PDF.
|
|
16
|
+
# - **anything else** (video, archives) → a failure; it can't be read.
|
|
17
|
+
class ReadAttachmentTool < Protege::Tool
|
|
18
|
+
description <<~DESC.strip
|
|
19
|
+
Read a file by the id shown in the conversation history (e.g. "id: 123") or returned by
|
|
20
|
+
create_file.
|
|
21
|
+
|
|
22
|
+
Text files (txt, csv, markdown, json, xml) are returned to you as text. Images and PDFs are
|
|
23
|
+
shown to you directly to view. Other types (such as video or archives) can't be read.
|
|
24
|
+
DESC
|
|
25
|
+
|
|
26
|
+
input_schema(
|
|
27
|
+
type: 'object',
|
|
28
|
+
additionalProperties: false,
|
|
29
|
+
required: %w[blob_id],
|
|
30
|
+
properties: {
|
|
31
|
+
blob_id: {
|
|
32
|
+
type: 'integer',
|
|
33
|
+
description: 'The id of the file to read, as shown in the conversation history.'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Resolve the file by blob id and read it according to its type.
|
|
39
|
+
#
|
|
40
|
+
# @param context [Protege::Orchestrator::Context] the per-run context (unused — resolution is by id)
|
|
41
|
+
# @param blob_id [Integer] the blob id from the conversation history or +create_file+
|
|
42
|
+
# @return [Protege::Result] text content, a review marker (image/PDF), or a failure
|
|
43
|
+
def use(context:, blob_id:)
|
|
44
|
+
file = Protege::StoredFile.find(blob_id)
|
|
45
|
+
return missing_failure(blob_id) unless file
|
|
46
|
+
|
|
47
|
+
if file.text?
|
|
48
|
+
text_result(file)
|
|
49
|
+
elsif file.viewable?
|
|
50
|
+
review_result(file)
|
|
51
|
+
else
|
|
52
|
+
unviewable_failure(file)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Success carrying the file's text content.
|
|
59
|
+
#
|
|
60
|
+
# @param file [Protege::StoredFile] the resolved file
|
|
61
|
+
# @return [Protege::Result] the text result
|
|
62
|
+
def text_result(file)
|
|
63
|
+
success(
|
|
64
|
+
filename: file.filename,
|
|
65
|
+
content_type: file.content_type,
|
|
66
|
+
content: file.download
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Success marking the file for visual review — only the blob id is carried, never the bytes; the
|
|
71
|
+
# harness injects the image/PDF as a vision part on the next user turn.
|
|
72
|
+
#
|
|
73
|
+
# @param file [Protege::StoredFile] the resolved file
|
|
74
|
+
# @return [Protege::Result] the review marker
|
|
75
|
+
def review_result(file)
|
|
76
|
+
success(
|
|
77
|
+
blob_id: file.id,
|
|
78
|
+
review: true,
|
|
79
|
+
filename: file.filename,
|
|
80
|
+
content_type: file.content_type,
|
|
81
|
+
note: "Showing #{file.filename} below for you to review."
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Failure for a blob id that matches no stored file.
|
|
86
|
+
#
|
|
87
|
+
# @param blob_id [Integer] the requested blob id
|
|
88
|
+
# @return [Protege::Result] the not-found failure
|
|
89
|
+
def missing_failure(blob_id)
|
|
90
|
+
failure(reason: "no file ##{blob_id}")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Failure for a file whose type can't be read (e.g. video, archive).
|
|
94
|
+
#
|
|
95
|
+
# @param file [Protege::StoredFile] the resolved file
|
|
96
|
+
# @return [Protege::Result] the unviewable failure
|
|
97
|
+
def unviewable_failure(file)
|
|
98
|
+
failure(reason: "attachment #{file.filename} (#{file.content_type}) can't be read")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Built-in archive-search tool — the way an agent looks back across its own correspondence beyond
|
|
5
|
+
# the current thread. Subclasses +Protege::Tool+, so the harness publishes it in the LLM's tool
|
|
6
|
+
# catalog (id +:search_emails+); when the model emits a +search_emails+ call, the harness routes the
|
|
7
|
+
# arguments to +#use+.
|
|
8
|
+
#
|
|
9
|
+
# The search is always scoped to the active persona's own messages (via +Persona#messages+) — an
|
|
10
|
+
# agent can never read another persona's mail. A single free-text query is matched, case-insensitively,
|
|
11
|
+
# against both the participants (from/to/cc) and the content (subject/body); results come back newest
|
|
12
|
+
# first. Each result carries the message's *full* body — a truncated body could cut the very text that
|
|
13
|
+
# matched, so it is returned whole. The caller may cap the result count with +limit+ (default
|
|
14
|
+
# {DEFAULT_LIMIT}, hard-capped at {MAX_LIMIT}); the default is small precisely because bodies are full.
|
|
15
|
+
class SearchEmailsTool < Protege::Tool
|
|
16
|
+
# Result count when the caller does not specify a +limit+. Kept small because each result carries
|
|
17
|
+
# the full message body.
|
|
18
|
+
DEFAULT_LIMIT = 5
|
|
19
|
+
|
|
20
|
+
# Absolute ceiling on the result count, regardless of the requested +limit+.
|
|
21
|
+
MAX_LIMIT = 50
|
|
22
|
+
|
|
23
|
+
# Columns scanned by the query: the participant addresses first, then the subject and bodies.
|
|
24
|
+
SEARCHABLE_COLUMNS = %w[
|
|
25
|
+
from_address to_addresses cc_addresses subject text_body html_body
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
description <<~DESC.strip
|
|
29
|
+
Search your own email archive for past messages — use this to recall earlier conversations that
|
|
30
|
+
are not part of the thread you are currently replying to. Only your own sent and received mail is
|
|
31
|
+
searched; you cannot see other agents' mail.
|
|
32
|
+
|
|
33
|
+
The query is matched against both participants (from / to / cc addresses) and content (subject and
|
|
34
|
+
body), so you can search by who was involved ("acme.co", "bob@acme.co") or by what was discussed
|
|
35
|
+
("Q3 invoice", "renewal"). Matching messages are returned newest first, each with its full body and
|
|
36
|
+
a list of any attachments (filename, type, size, and id) — use an id with read_attachment to view
|
|
37
|
+
one or with send_email to re-attach it.
|
|
38
|
+
|
|
39
|
+
Provide a single query string. Optionally set limit to cap the number of results (default 5,
|
|
40
|
+
maximum 50) — keep it small, since every result includes the whole message body. Narrow the query
|
|
41
|
+
rather than raising the limit when you get too many hits.
|
|
42
|
+
DESC
|
|
43
|
+
|
|
44
|
+
input_schema(
|
|
45
|
+
type: 'object',
|
|
46
|
+
additionalProperties: false,
|
|
47
|
+
required: %w[query],
|
|
48
|
+
properties: {
|
|
49
|
+
query: {
|
|
50
|
+
type: 'string',
|
|
51
|
+
description: 'Free-text search matched against participants (from/to/cc) and content ' \
|
|
52
|
+
'(subject/body).'
|
|
53
|
+
},
|
|
54
|
+
limit: {
|
|
55
|
+
type: 'integer',
|
|
56
|
+
description: 'Maximum number of results to return. Defaults to 5; capped at 50.'
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# Search the active persona's archive and return the matching messages as concise summaries.
|
|
62
|
+
#
|
|
63
|
+
# @param context [Protege::Orchestrator::Context] exposes the active +persona+
|
|
64
|
+
# @param query [String] the free-text query supplied by the model
|
|
65
|
+
# @param limit [Integer, nil] optional result cap (defaults to {DEFAULT_LIMIT}, capped at {MAX_LIMIT})
|
|
66
|
+
# @return [Protege::Result] success carrying +count+ and the shaped +results+, or a failure when
|
|
67
|
+
# the query is blank
|
|
68
|
+
def use(context:, query:, limit: nil)
|
|
69
|
+
return failure(reason: 'query cannot be empty') if query.to_s.strip.empty?
|
|
70
|
+
|
|
71
|
+
hits = matches(persona: context.persona, query:, limit:)
|
|
72
|
+
|
|
73
|
+
success(count: hits.size, results: hits.map { result_hash(_1) })
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Build and run the scoped, wildcard-escaped LIKE query, newest first.
|
|
79
|
+
#
|
|
80
|
+
# @param persona [Protege::Persona] the persona whose messages are searched
|
|
81
|
+
# @param query [String] the raw query supplied by the model
|
|
82
|
+
# @param limit [Integer, nil] the requested cap, clamped into 1..{MAX_LIMIT}
|
|
83
|
+
# @return [Array<Protege::Message>] the matching messages, newest first
|
|
84
|
+
def matches(persona:, query:, limit:)
|
|
85
|
+
count = (limit || DEFAULT_LIMIT).to_i.clamp(1, MAX_LIMIT)
|
|
86
|
+
escaped = ActiveRecord::Base.sanitize_sql_like(query.strip.downcase)
|
|
87
|
+
clause = SEARCHABLE_COLUMNS.map { "LOWER(#{_1}) LIKE :term" }.join(' OR ')
|
|
88
|
+
|
|
89
|
+
persona.messages
|
|
90
|
+
.with_attached_attachments
|
|
91
|
+
.where(clause, term: "%#{escaped}%")
|
|
92
|
+
.order(sent_at: :desc)
|
|
93
|
+
.limit(count)
|
|
94
|
+
.to_a
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Shape a single message into a result summary. The body is returned in full (never truncated) so
|
|
98
|
+
# the matched text is always present, and any attachments are listed so the model can pull one into
|
|
99
|
+
# view with +read_attachment+ or re-send it with +send_email+ by its id.
|
|
100
|
+
#
|
|
101
|
+
# @param message [Protege::Message] the matched message
|
|
102
|
+
# @return [Hash] the summary the model reasons over
|
|
103
|
+
def result_hash(message)
|
|
104
|
+
base = {
|
|
105
|
+
message_id: message.message_id,
|
|
106
|
+
thread_id: message.email_thread.thread_id,
|
|
107
|
+
direction: message.direction,
|
|
108
|
+
from_address: message.from_address,
|
|
109
|
+
to_addresses: message.to_addresses,
|
|
110
|
+
subject: message.subject,
|
|
111
|
+
sent_at: message.sent_at.iso8601,
|
|
112
|
+
body: message.readable_body
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
summaries = attachment_summaries(message)
|
|
116
|
+
summaries.empty? ? base : base.merge(attachments: summaries)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# One-line summaries (filename, type, human size, blob id) for every attachment on the message,
|
|
120
|
+
# built from the shared +Protege::StoredFile+ wrapper so search matches the format (and the blob
|
|
121
|
+
# id) used in thread history and +read_attachment+.
|
|
122
|
+
#
|
|
123
|
+
# @param message [Protege::Message] the matched message
|
|
124
|
+
# @return [Array<String>] the per-attachment summaries (empty when the message has none)
|
|
125
|
+
def attachment_summaries(message)
|
|
126
|
+
message.attachments.map { Protege::StoredFile.new(_1.blob).summary }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Protege
|
|
4
|
+
# Built-in email tool — the way an agent sends mail, in one of two modes. Subclasses
|
|
5
|
+
# +Protege::Tool+, so the harness publishes it in the LLM's tool catalog (id +:send_email+);
|
|
6
|
+
# when the model emits a +send_email+ call, the harness routes the arguments to +#use+.
|
|
7
|
+
#
|
|
8
|
+
# +From+ is always enforced to the persona's +email_address+ — the agent cannot impersonate another
|
|
9
|
+
# sender. The +mode+ argument selects behavior:
|
|
10
|
+
#
|
|
11
|
+
# * +"reply"+ — continue the current conversation. +Subject+ and threading (+In-Reply-To+ +
|
|
12
|
+
# +References+) are derived from the inbound message; the subject is the *thread's* subject, +Re:+
|
|
13
|
+
# prefixed, NOT a value the model chooses (Gmail and others require a matching subject to thread, so
|
|
14
|
+
# a model-invented subject would split the conversation). The original sender is always a recipient;
|
|
15
|
+
# the model may add more via +to+/+cc+/+bcc+.
|
|
16
|
+
# * +"new"+ — start a fresh conversation. The model supplies +to+ and +subject+ (and optional
|
|
17
|
+
# +cc+/+bcc+); no threading headers are set.
|
|
18
|
+
#
|
|
19
|
+
# Assembly is delegated to +Protege::Gateway.build_outbound+. The agent may attach files by
|
|
20
|
+
# referencing blob ids — the ids shown in the conversation history, or returned by +create_file+ —
|
|
21
|
+
# resolved through +Protege::StoredFile.find+ and checked against the configured
|
|
22
|
+
# +Gateway::AttachmentPolicy+ limits. (Blob ids currently resolve unscoped; persona scoping is a
|
|
23
|
+
# deferred pass — see the blob-currency spec §9.)
|
|
24
|
+
class SendEmailTool < Protege::Tool
|
|
25
|
+
description <<~DESC.strip
|
|
26
|
+
YOU MUST USE THIS TOOL FOR EVERY RESPONSE. This is the ONLY way to communicate with the user.
|
|
27
|
+
They will never receive plain text in your assistant message If you want them to read something,#{' '}
|
|
28
|
+
you must use this send_email tool.#{' '}
|
|
29
|
+
|
|
30
|
+
Set mode:
|
|
31
|
+
|
|
32
|
+
- "reply": continue the current conversation. The subject and threading are kept automatically and
|
|
33
|
+
the original sender is included; add more recipients with to/cc/bcc if you need to.
|
|
34
|
+
- "new": start a new email. Provide to and subject.
|
|
35
|
+
|
|
36
|
+
Always provide body. cc and bcc are optional in both modes. Attach files with attachments — a list
|
|
37
|
+
of blob ids (e.g. the "id: 123" shown next to a file in the conversation, or the id returned by
|
|
38
|
+
create_file).
|
|
39
|
+
DESC
|
|
40
|
+
|
|
41
|
+
input_schema(
|
|
42
|
+
type: 'object',
|
|
43
|
+
additionalProperties: false,
|
|
44
|
+
required: %w[mode body],
|
|
45
|
+
properties: {
|
|
46
|
+
mode: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
enum: %w[reply new],
|
|
49
|
+
description: '"reply" to continue the current conversation, or "new" to start a new email.'
|
|
50
|
+
},
|
|
51
|
+
body: { type: 'string', description: 'Plain text body.' },
|
|
52
|
+
to: {
|
|
53
|
+
type: 'array',
|
|
54
|
+
items: { type: 'string' },
|
|
55
|
+
description: 'Recipients. Required for "new"; for "reply", added beyond the original sender.'
|
|
56
|
+
},
|
|
57
|
+
cc: {
|
|
58
|
+
type: 'array',
|
|
59
|
+
items: { type: 'string' },
|
|
60
|
+
description: 'Optional carbon-copy recipients.'
|
|
61
|
+
},
|
|
62
|
+
bcc: {
|
|
63
|
+
type: 'array',
|
|
64
|
+
items: { type: 'string' },
|
|
65
|
+
description: 'Optional blind-carbon-copy recipients.'
|
|
66
|
+
},
|
|
67
|
+
subject: {
|
|
68
|
+
type: 'string',
|
|
69
|
+
description: 'Subject for a "new" email. Ignored when replying (the thread subject is kept).'
|
|
70
|
+
},
|
|
71
|
+
attachments: {
|
|
72
|
+
type: 'array',
|
|
73
|
+
items: { type: 'integer' },
|
|
74
|
+
description: 'Optional blob ids (from the conversation history or create_file) to attach.'
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Build the outbound mail for the requested mode and hand it to the gateway for delivery.
|
|
80
|
+
#
|
|
81
|
+
# @param context [Protege::Orchestrator::Context] exposes the inbound +message+, +persona+, and +deliver+
|
|
82
|
+
# @param mode [String] +"reply"+ or +"new"+
|
|
83
|
+
# @param body [String] plain-text body supplied by the model
|
|
84
|
+
# @param to [Array<String>] recipients (required for +"new"+; additions for +"reply"+)
|
|
85
|
+
# @param cc [Array<String>] carbon-copy recipients
|
|
86
|
+
# @param bcc [Array<String>] blind-carbon-copy recipients
|
|
87
|
+
# @param subject [String, nil] subject for a +"new"+ email; ignored when replying
|
|
88
|
+
# @param attachments [Array<Integer>] blob ids to attach (from history or +create_file+)
|
|
89
|
+
# @return [Protege::Result] success carrying the sent message's id, or a failure (unknown blob,
|
|
90
|
+
# breached limits, nothing to reply to, or a new email missing +to+/+subject+)
|
|
91
|
+
def use(context:, mode:, body:, to: [], cc: [], bcc: [], subject: nil, attachments: [])
|
|
92
|
+
files = resolve_attachments(attachments)
|
|
93
|
+
return files if files.is_a?(Protege::Result) # a failure short-circuits
|
|
94
|
+
|
|
95
|
+
mail = build_mail(
|
|
96
|
+
context:, mode:, to:, cc:, bcc:, subject:, body:, attachments: files.map(&:to_mail_attachment)
|
|
97
|
+
)
|
|
98
|
+
return mail if mail.is_a?(Protege::Result) # a validation failure short-circuits
|
|
99
|
+
|
|
100
|
+
mail.encoded
|
|
101
|
+
# Hand the blobs to the gateway so the persisted outbound message attaches them by reference
|
|
102
|
+
# (no re-upload) — the mail carries the bytes only for transport.
|
|
103
|
+
context.deliver(mail:, attachments: files.map(&:blob))
|
|
104
|
+
|
|
105
|
+
success(message_id: mail.message_id)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# Build the outbound mail for the requested mode.
|
|
111
|
+
#
|
|
112
|
+
# @param context [Protege::Orchestrator::Context] supplies the inbound message and persona
|
|
113
|
+
# @param mode [String] +"reply"+ or +"new"+
|
|
114
|
+
# @param to [Array<String>] recipients
|
|
115
|
+
# @param cc [Array<String>] carbon-copy recipients
|
|
116
|
+
# @param bcc [Array<String>] blind-carbon-copy recipients
|
|
117
|
+
# @param subject [String, nil] subject for a new email
|
|
118
|
+
# @param body [String] the plain-text body
|
|
119
|
+
# @param attachments [Array<Gateway::Mail::Attachment>] the resolved mail attachments
|
|
120
|
+
# @return [Mail::Message, Protege::Result] the assembled mail, or a failure
|
|
121
|
+
def build_mail(context:, mode:, to:, cc:, bcc:, subject:, body:, attachments:)
|
|
122
|
+
case mode
|
|
123
|
+
when 'reply' then reply_mail(context, to:, cc:, bcc:, body:, attachments:)
|
|
124
|
+
when 'new' then new_thread_mail(context.persona, to:, cc:, bcc:, subject:, body:, attachments:)
|
|
125
|
+
else failure(reason: %(unknown mode #{mode.inspect}; use "reply" or "new"))
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Construct a threaded reply: subject and threading from the inbound message, the original sender
|
|
130
|
+
# plus any added recipients.
|
|
131
|
+
#
|
|
132
|
+
# @param context [Protege::Orchestrator::Context] supplies the inbound message and persona
|
|
133
|
+
# @param to [Array<String>] recipients to add beyond the original sender
|
|
134
|
+
# @param cc [Array<String>] carbon-copy recipients
|
|
135
|
+
# @param bcc [Array<String>] blind-carbon-copy recipients
|
|
136
|
+
# @param body [String] the plain-text body
|
|
137
|
+
# @param attachments [Array<Gateway::Mail::Attachment>] the resolved mail attachments
|
|
138
|
+
# @return [Mail::Message, Protege::Result] the assembled reply, or a failure when there is no inbound
|
|
139
|
+
def reply_mail(context, to:, cc:, bcc:, body:, attachments:)
|
|
140
|
+
inbound = context.message
|
|
141
|
+
return failure(reason: 'there is no message to reply to; send a "new" email instead') unless inbound
|
|
142
|
+
|
|
143
|
+
Protege::Gateway.build_outbound(
|
|
144
|
+
from: context.persona.email_address,
|
|
145
|
+
to: ([inbound.from_address] + addresses(to)).uniq,
|
|
146
|
+
cc: addresses(cc),
|
|
147
|
+
bcc: addresses(bcc),
|
|
148
|
+
subject: ensure_re_prefix(inbound.subject),
|
|
149
|
+
body:,
|
|
150
|
+
in_reply_to: inbound.message_id,
|
|
151
|
+
references: build_references(inbound),
|
|
152
|
+
attachments:
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Construct a new-thread email: the model supplies the recipients and subject; no threading headers.
|
|
157
|
+
#
|
|
158
|
+
# @param persona [Protege::Persona] the sending persona (enforced From)
|
|
159
|
+
# @param to [Array<String>] recipients (at least one required)
|
|
160
|
+
# @param cc [Array<String>] carbon-copy recipients
|
|
161
|
+
# @param bcc [Array<String>] blind-carbon-copy recipients
|
|
162
|
+
# @param subject [String, nil] the subject (required)
|
|
163
|
+
# @param body [String] the plain-text body
|
|
164
|
+
# @param attachments [Array<Gateway::Mail::Attachment>] the resolved mail attachments
|
|
165
|
+
# @return [Mail::Message, Protege::Result] the assembled mail, or a failure when +to+/+subject+ is missing
|
|
166
|
+
def new_thread_mail(persona, to:, cc:, bcc:, subject:, body:, attachments:)
|
|
167
|
+
recipients = addresses(to)
|
|
168
|
+
return failure(reason: 'a new email needs at least one "to" recipient') if recipients.empty?
|
|
169
|
+
return failure(reason: 'a new email needs a subject') if subject.to_s.strip.empty?
|
|
170
|
+
|
|
171
|
+
Protege::Gateway.build_outbound(
|
|
172
|
+
from: persona.email_address,
|
|
173
|
+
to: recipients,
|
|
174
|
+
cc: addresses(cc),
|
|
175
|
+
bcc: addresses(bcc),
|
|
176
|
+
subject:,
|
|
177
|
+
body:,
|
|
178
|
+
attachments:
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Normalize a recipients argument to a clean array of address strings (blank entries dropped).
|
|
183
|
+
#
|
|
184
|
+
# @param value [String, Array<String>, nil] the raw recipients
|
|
185
|
+
# @return [Array<String>] the cleaned addresses
|
|
186
|
+
def addresses(value)
|
|
187
|
+
Array(value).map { |address| address.to_s.strip }.reject(&:empty?)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Resolve blob ids to the stored files they name, limit-checked.
|
|
191
|
+
#
|
|
192
|
+
# @param ids [Array<Integer>] the requested blob ids
|
|
193
|
+
# @return [Array<Protege::StoredFile>, Protege::Result] the resolved files, or a failure +Result+
|
|
194
|
+
# when a blob id is unknown or the selection breaches the limits
|
|
195
|
+
def resolve_attachments(ids)
|
|
196
|
+
resolved = Array(ids).map { |id| [id, Protege::StoredFile.find(id)] }
|
|
197
|
+
|
|
198
|
+
missing_id = first_missing_id(resolved)
|
|
199
|
+
return failure(reason: "no file ##{missing_id}") if missing_id
|
|
200
|
+
|
|
201
|
+
files = resolved.map(&:last)
|
|
202
|
+
violation = limit_violation(files)
|
|
203
|
+
return failure(reason: violation) if violation
|
|
204
|
+
|
|
205
|
+
files
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# The first requested blob id that resolved to no file, or +nil+ when all were found.
|
|
209
|
+
#
|
|
210
|
+
# @param resolved [Array<Array(Integer, Protege::StoredFile, nil)>] id → file pairs
|
|
211
|
+
# @return [Integer, nil] the missing id, or nil
|
|
212
|
+
def first_missing_id(resolved)
|
|
213
|
+
resolved.find { |_id, file| file.nil? }&.first
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# The configured attachment-limit breach for the selected files, or +nil+ when within limits.
|
|
217
|
+
#
|
|
218
|
+
# @param files [Array<Protege::StoredFile>] the resolved files
|
|
219
|
+
# @return [String, nil] the limit-breach reason, or nil
|
|
220
|
+
def limit_violation(files)
|
|
221
|
+
Protege.configuration.attachment_policy.violation(byte_sizes: files.map(&:byte_size))
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Assemble the +References+ chain, extending the inbound chain with the inbound's own id.
|
|
225
|
+
#
|
|
226
|
+
# @param inbound [Protege::Message] the message being replied to
|
|
227
|
+
# @return [Array<String>] the de-duplicated references chain
|
|
228
|
+
def build_references(inbound)
|
|
229
|
+
(inbound.references_header.to_s.split + [inbound.message_id]).compact.uniq
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Ensure the subject carries a single +Re:+ prefix.
|
|
233
|
+
#
|
|
234
|
+
# @param subject [String] the raw subject line
|
|
235
|
+
# @return [String] the subject, prefixed with +Re:+ unless it already starts with one
|
|
236
|
+
def ensure_re_prefix(subject)
|
|
237
|
+
subject.to_s.strip.match?(/\Are:/i) ? subject : "Re: #{subject}"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|