protege 0.1.0.alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (255) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +24 -0
  3. data/Rakefile +12 -0
  4. data/app/assets/builds/protege.css +1128 -0
  5. data/app/assets/javascripts/protege/attachment_picker.js +163 -0
  6. data/app/assets/javascripts/protege/copy.js +80 -0
  7. data/app/assets/javascripts/protege/forms.js +28 -0
  8. data/app/assets/javascripts/protege/keys.js +212 -0
  9. data/app/assets/javascripts/protege/resize.js +149 -0
  10. data/app/assets/javascripts/protege/theme.js +25 -0
  11. data/app/assets/stylesheets/protege/application.css +15 -0
  12. data/app/assets/tailwind/application.css +137 -0
  13. data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
  14. data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
  15. data/app/controllers/protege/access_rules_controller.rb +72 -0
  16. data/app/controllers/protege/application_controller.rb +18 -0
  17. data/app/controllers/protege/archives_controller.rb +52 -0
  18. data/app/controllers/protege/email_domains_controller.rb +72 -0
  19. data/app/controllers/protege/home_controller.rb +15 -0
  20. data/app/controllers/protege/messages_controller.rb +46 -0
  21. data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
  22. data/app/controllers/protege/personas_controller.rb +118 -0
  23. data/app/controllers/protege/replies_controller.rb +64 -0
  24. data/app/controllers/protege/responsibilities_controller.rb +82 -0
  25. data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
  26. data/app/controllers/protege/threads_controller.rb +75 -0
  27. data/app/controllers/protege/traces/searches_controller.rb +19 -0
  28. data/app/controllers/protege/traces_controller.rb +52 -0
  29. data/app/helpers/protege/application_helper.rb +30 -0
  30. data/app/helpers/protege/components/badges_helper.rb +59 -0
  31. data/app/helpers/protege/components/buttons_helper.rb +141 -0
  32. data/app/helpers/protege/components/cards_helper.rb +48 -0
  33. data/app/helpers/protege/components/components_helper.rb +30 -0
  34. data/app/helpers/protege/components/dialogs_helper.rb +36 -0
  35. data/app/helpers/protege/components/forms_helper.rb +223 -0
  36. data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
  37. data/app/helpers/protege/components/icons_helper.rb +41 -0
  38. data/app/helpers/protege/components/introspection_helper.rb +162 -0
  39. data/app/helpers/protege/components/layout_helper.rb +169 -0
  40. data/app/helpers/protege/components/menus_helper.rb +68 -0
  41. data/app/helpers/protege/components/pagination_helper.rb +100 -0
  42. data/app/helpers/protege/components/sidebar_helper.rb +211 -0
  43. data/app/helpers/protege/components/tables_helper.rb +59 -0
  44. data/app/helpers/protege/components/typography_helper.rb +64 -0
  45. data/app/helpers/protege/email_domains_helper.rb +22 -0
  46. data/app/helpers/protege/messages_helper.rb +152 -0
  47. data/app/helpers/protege/personas_helper.rb +90 -0
  48. data/app/helpers/protege/responsibilities_helper.rb +63 -0
  49. data/app/helpers/protege/threads_helper.rb +118 -0
  50. data/app/helpers/protege/trace_helper.rb +22 -0
  51. data/app/hooks/protege/event_logger_hook.rb +109 -0
  52. data/app/jobs/protege/application_job.rb +41 -0
  53. data/app/jobs/protege/console_inference_job.rb +57 -0
  54. data/app/jobs/protege/inference_job.rb +60 -0
  55. data/app/jobs/protege/mail_provisioning_job.rb +21 -0
  56. data/app/jobs/protege/responsibility_job.rb +70 -0
  57. data/app/jobs/protege/responsibility_tick_job.rb +19 -0
  58. data/app/mailboxes/protege/agent_mailbox.rb +123 -0
  59. data/app/mailers/protege/application_mailer.rb +65 -0
  60. data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
  61. data/app/models/concerns/protege/broadcastable_message.rb +47 -0
  62. data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
  63. data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
  64. data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
  65. data/app/models/concerns/protege/tool_scoped.rb +88 -0
  66. data/app/models/protege/access_rule.rb +50 -0
  67. data/app/models/protege/application_record.rb +18 -0
  68. data/app/models/protege/current.rb +23 -0
  69. data/app/models/protege/email_domain.rb +136 -0
  70. data/app/models/protege/email_thread.rb +81 -0
  71. data/app/models/protege/message.rb +401 -0
  72. data/app/models/protege/persona.rb +229 -0
  73. data/app/models/protege/responsibility.rb +98 -0
  74. data/app/models/protege/responsibility_run.rb +68 -0
  75. data/app/models/protege/tool_use.rb +115 -0
  76. data/app/models/protege/trace.rb +71 -0
  77. data/app/providers/protege/open_router_provider.rb +317 -0
  78. data/app/resolvers/README.md +255 -0
  79. data/app/resolvers/protege/load_file_resolver.rb +86 -0
  80. data/app/resolvers/protege/load_record_resolver.rb +80 -0
  81. data/app/resolvers/protege/load_text_resolver.rb +40 -0
  82. data/app/resolvers/protege/thread_history_resolver.rb +116 -0
  83. data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
  84. data/app/services/protege/message_search.rb +114 -0
  85. data/app/services/protege/trace_search.rb +102 -0
  86. data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
  87. data/app/tools/protege/create_file_tool.rb +109 -0
  88. data/app/tools/protege/read_attachment_tool.rb +101 -0
  89. data/app/tools/protege/search_emails_tool.rb +129 -0
  90. data/app/tools/protege/send_email_tool.rb +240 -0
  91. data/app/tools/protege/web_fetch_tool.rb +224 -0
  92. data/app/tools/protege/web_search_tool.rb +131 -0
  93. data/app/views/layouts/protege/application.html.slim +19 -0
  94. data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
  95. data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
  96. data/app/views/protege/email_domains/_form.html.slim +10 -0
  97. data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
  98. data/app/views/protege/email_domains/index.html.slim +6 -0
  99. data/app/views/protege/email_domains/new.html.slim +7 -0
  100. data/app/views/protege/email_domains/show.html.slim +36 -0
  101. data/app/views/protege/home/show.html.slim +26 -0
  102. data/app/views/protege/messages/_message.html.slim +19 -0
  103. data/app/views/protege/messages/_sidebar.html.slim +13 -0
  104. data/app/views/protege/messages/index.html.slim +20 -0
  105. data/app/views/protege/personas/_access_rule.html.slim +7 -0
  106. data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
  107. data/app/views/protege/personas/_access_rules.html.slim +19 -0
  108. data/app/views/protege/personas/_actions.html.slim +15 -0
  109. data/app/views/protege/personas/_form.html.slim +29 -0
  110. data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
  111. data/app/views/protege/personas/_sidebar.html.slim +11 -0
  112. data/app/views/protege/personas/edit.html.slim +7 -0
  113. data/app/views/protege/personas/index.html.slim +6 -0
  114. data/app/views/protege/personas/new.html.slim +7 -0
  115. data/app/views/protege/personas/show.html.slim +37 -0
  116. data/app/views/protege/responsibilities/_form.html.slim +21 -0
  117. data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
  118. data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
  119. data/app/views/protege/responsibilities/edit.html.slim +7 -0
  120. data/app/views/protege/responsibilities/index.html.slim +6 -0
  121. data/app/views/protege/responsibilities/new.html.slim +7 -0
  122. data/app/views/protege/responsibilities/show.html.slim +22 -0
  123. data/app/views/protege/responsibility_runs/show.html.slim +13 -0
  124. data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
  125. data/app/views/protege/shared/_header.html.slim +14 -0
  126. data/app/views/protege/shared/_hotkeys.html.slim +43 -0
  127. data/app/views/protege/threads/_composer_footer.html.slim +15 -0
  128. data/app/views/protege/threads/_introspection.html.slim +7 -0
  129. data/app/views/protege/threads/_reasoning.html.slim +4 -0
  130. data/app/views/protege/threads/_reply_form.html.slim +7 -0
  131. data/app/views/protege/threads/_run_marker.html.slim +3 -0
  132. data/app/views/protege/threads/_sidebar.html.slim +15 -0
  133. data/app/views/protege/threads/_thread.html.slim +6 -0
  134. data/app/views/protege/threads/_tool_call.html.slim +8 -0
  135. data/app/views/protege/threads/index.html.slim +6 -0
  136. data/app/views/protege/threads/new.html.slim +15 -0
  137. data/app/views/protege/threads/show.html.slim +25 -0
  138. data/app/views/protege/traces/_sidebar.html.slim +15 -0
  139. data/app/views/protege/traces/index.html.slim +11 -0
  140. data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
  141. data/app/views/protege/traces/searches/show.html.slim +22 -0
  142. data/app/views/protege/traces/show.html.slim +28 -0
  143. data/config/routes.rb +33 -0
  144. data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
  145. data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
  146. data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
  147. data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
  148. data/lib/generators/protege/extension_naming.rb +38 -0
  149. data/lib/generators/protege/hook/hook_generator.rb +33 -0
  150. data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
  151. data/lib/generators/protege/install/install_generator.rb +170 -0
  152. data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
  153. data/lib/generators/protege/persona/persona_generator.rb +35 -0
  154. data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
  155. data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
  156. data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
  157. data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
  158. data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
  159. data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
  160. data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
  161. data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
  162. data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
  163. data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
  164. data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
  165. data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
  166. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
  167. data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
  168. data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
  169. data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
  170. data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
  171. data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
  172. data/lib/generators/protege/provider/provider_generator.rb +44 -0
  173. data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
  174. data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
  175. data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
  176. data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
  177. data/lib/generators/protege/tool/tool_generator.rb +34 -0
  178. data/lib/protege/configuration.rb +171 -0
  179. data/lib/protege/engine.rb +103 -0
  180. data/lib/protege/errors/bad_request_error.rb +8 -0
  181. data/lib/protege/errors/contract_violation_error.rb +8 -0
  182. data/lib/protege/errors/error.rb +30 -0
  183. data/lib/protege/errors/generic_error.rb +7 -0
  184. data/lib/protege/errors/internal_error.rb +8 -0
  185. data/lib/protege/errors/invalid_model_error.rb +8 -0
  186. data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
  187. data/lib/protege/errors/rate_limited_error.rb +8 -0
  188. data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
  189. data/lib/protege/errors/response_parse_failed_error.rb +8 -0
  190. data/lib/protege/errors/timeout_error.rb +8 -0
  191. data/lib/protege/errors/tool_not_available_error.rb +12 -0
  192. data/lib/protege/errors/tool_not_found_error.rb +9 -0
  193. data/lib/protege/errors/unauthorized_error.rb +8 -0
  194. data/lib/protege/errors/unavailable_error.rb +8 -0
  195. data/lib/protege/errors/unsupported_capability_error.rb +8 -0
  196. data/lib/protege/events/event.rb +98 -0
  197. data/lib/protege/events/inference_chunk_event.rb +17 -0
  198. data/lib/protege/events/inference_completed_event.rb +12 -0
  199. data/lib/protege/events/inference_failed_event.rb +12 -0
  200. data/lib/protege/events/inference_generated_event.rb +19 -0
  201. data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
  202. data/lib/protege/events/inference_started_event.rb +11 -0
  203. data/lib/protege/events/loop_run_completed_event.rb +11 -0
  204. data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
  205. data/lib/protege/events/loop_run_failed_event.rb +12 -0
  206. data/lib/protege/events/loop_run_started_event.rb +11 -0
  207. data/lib/protege/events/tool_call_completed_event.rb +12 -0
  208. data/lib/protege/events/tool_call_failed_event.rb +12 -0
  209. data/lib/protege/events/tool_call_started_event.rb +11 -0
  210. data/lib/protege/events/tool_calls_received_event.rb +11 -0
  211. data/lib/protege/extensions/hook.rb +13 -0
  212. data/lib/protege/extensions/hook_mixin.rb +90 -0
  213. data/lib/protege/extensions/provider.rb +12 -0
  214. data/lib/protege/extensions/provider_mixin.rb +124 -0
  215. data/lib/protege/extensions/resolver.rb +12 -0
  216. data/lib/protege/extensions/resolver_mixin.rb +58 -0
  217. data/lib/protege/extensions/tool.rb +12 -0
  218. data/lib/protege/extensions/tool_mixin.rb +217 -0
  219. data/lib/protege/gateway/access_control.rb +66 -0
  220. data/lib/protege/gateway/access_policy.rb +94 -0
  221. data/lib/protege/gateway/attachment_policy.rb +115 -0
  222. data/lib/protege/gateway/mail/address.rb +145 -0
  223. data/lib/protege/gateway/mail/attachment.rb +17 -0
  224. data/lib/protege/gateway/mail/message_id.rb +104 -0
  225. data/lib/protege/gateway/mail/outbound.rb +181 -0
  226. data/lib/protege/gateway/mail/references.rb +107 -0
  227. data/lib/protege/gateway.rb +398 -0
  228. data/lib/protege/inference/provider/request.rb +110 -0
  229. data/lib/protege/inference/provider/response.rb +50 -0
  230. data/lib/protege/inference/provider/tool_call.rb +52 -0
  231. data/lib/protege/inference.rb +121 -0
  232. data/lib/protege/loop/schedule.rb +66 -0
  233. data/lib/protege/loop/scheduler.rb +42 -0
  234. data/lib/protege/loop.rb +37 -0
  235. data/lib/protege/orchestrator/context.rb +65 -0
  236. data/lib/protege/orchestrator/harness.rb +305 -0
  237. data/lib/protege/orchestrator/reply_context.rb +21 -0
  238. data/lib/protege/orchestrator/reply_harness.rb +68 -0
  239. data/lib/protege/orchestrator/resolver_chain.rb +93 -0
  240. data/lib/protege/orchestrator/responsibility_context.rb +22 -0
  241. data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
  242. data/lib/protege/orchestrator.rb +37 -0
  243. data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
  244. data/lib/protege/subscribers/tracing.rb +65 -0
  245. data/lib/protege/types/document_part.rb +13 -0
  246. data/lib/protege/types/image_part.rb +11 -0
  247. data/lib/protege/types/model_message.rb +40 -0
  248. data/lib/protege/types/result.rb +113 -0
  249. data/lib/protege/types/stored_file.rb +127 -0
  250. data/lib/protege/types/text_part.rb +8 -0
  251. data/lib/protege/types/tool_result.rb +16 -0
  252. data/lib/protege/version.rb +6 -0
  253. data/lib/protege.rb +47 -0
  254. data/lib/tasks/protege_tasks.rake +16 -0
  255. metadata +494 -0
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Subscribers
5
+ # The hooks-only event dispatcher — the runtime that activates hooks against the engine's event
6
+ # stream. Installed once at boot by +Protege::Engine+ (a peer of +Subscribers::Tracing+ and
7
+ # +Subscribers::IntrospectionBroadcaster+); it subscribes one dispatcher per +Protege::Event.all+
8
+ # that, when an event fires, runs every registered hook's matching handlers.
9
+ #
10
+ # Kept separate from the hook *contract* (+Protege::HookMixin+, the +on+/+stateful+ DSL a hook
11
+ # declares with): this module only reads that contract off each hook class
12
+ # (+hook_class.subscriptions+, +hook_class.stateful?+) — a one-way dependency, dispatcher → contract.
13
+ #
14
+ # Hooks are pulled live from +Hook.descendants+ (via {registered}) when an event fires, so a Zeitwerk
15
+ # reload just changes the descendant set; the dispatchers are stable and never need re-subscribing.
16
+ # Handler blocks run via +instance_exec+ on a hook instance — fresh per event for stateless hooks, or
17
+ # a shared per-run instance (keyed by +correlation_id+) for +stateful+ ones — inside a +rescue+ that
18
+ # logs and swallows, so one hook can never break inference or a sibling hook.
19
+ module HookDispatcher
20
+ extend self
21
+
22
+ # Event classes that end a run; a stateful hook's per-run instance is evicted after these fire.
23
+ # Includes +InferenceFailedEvent+ so a run that raises before completing never leaves a stale
24
+ # instance behind.
25
+ TERMINAL_EVENTS = [
26
+ InferenceCompletedEvent,
27
+ InferenceFailedEvent,
28
+ InferenceMaxTurnsReachedEvent
29
+ ].freeze
30
+
31
+ # Live event-bus dispatcher handles (one per event class), retained so {reset!} can detach them.
32
+ @handles = []
33
+
34
+ # Per-run instances for stateful hooks, keyed by +[hook_class, correlation_id]+.
35
+ @instances = {}
36
+
37
+ # Guards (re)installation of the dispatchers.
38
+ @install_mutex = Mutex.new
39
+
40
+ # Guards the stateful-instance map, mutated from event-dispatch threads.
41
+ @instances_mutex = Mutex.new
42
+
43
+ # Wire the event bus to hook dispatch — the subscriber's +install!+ contract. Called once at boot
44
+ # by the engine; idempotent, so a repeat call is a no-op until {reset!} runs. Installs one
45
+ # dispatcher per +Protege::Event.all+ that fans the event out to every registered hook's handlers.
46
+ #
47
+ # @return [void]
48
+ def install!
49
+ @install_mutex.synchronize do
50
+ return unless @handles.empty?
51
+
52
+ @handles = Protege::Event.all.map do |event_class|
53
+ event_class.subscribe { |event| dispatch(event_class, event) }
54
+ end
55
+ end
56
+ end
57
+
58
+ # Detach every dispatcher and drop all per-run state — the subscriber's +reset!+ contract, for boot
59
+ # re-wiring and tests.
60
+ #
61
+ # @return [void]
62
+ def reset!
63
+ @install_mutex.synchronize do
64
+ @handles.each { |handle| ActiveSupport::Notifications.unsubscribe(handle) }
65
+ @handles = []
66
+ @instances_mutex.synchronize { @instances.clear }
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ # List all concrete hook classes to dispatch to.
73
+ #
74
+ # Resolved via +Hook.descendants+ so it survives Zeitwerk reloads without manual bookkeeping.
75
+ # Anonymous classes (nil name) are excluded.
76
+ #
77
+ # @return [Array<Class>] the registered hook classes
78
+ def registered
79
+ Hook.descendants.reject { _1.name.nil? }
80
+ end
81
+
82
+ # Fan one fired event out to every registered hook that declared a handler for its class.
83
+ #
84
+ # @param event_class [Class] the fired event's class
85
+ # @param event [Protege::Event] the typed event instance
86
+ # @return [void]
87
+ def dispatch(event_class, event)
88
+ registered.each do |hook_class|
89
+ # A hook only has a subscriptions key for the events it knew about when its map was seeded.
90
+ # An event class it has no entry for (an unknown or newly-added event) yields nil — treat that
91
+ # as "no handlers" and skip, rather than crashing on nil.empty?.
92
+ blocks = hook_class.subscriptions[event_class]
93
+ run(hook_class:, event_class:, blocks:, event:) if blocks&.any?
94
+ end
95
+ end
96
+
97
+ # Run a hook's handlers for one event, isolated and on the right instance.
98
+ #
99
+ # @param hook_class [Class] the hook class
100
+ # @param event_class [Class] the event class being dispatched
101
+ # @param blocks [Array<Proc>] the handler blocks for this event
102
+ # @param event [Protege::Event] the typed event
103
+ # @return [void]
104
+ def run(hook_class:, event_class:, blocks:, event:)
105
+ instance = instance_for(hook_class, event)
106
+
107
+ blocks.each do |block|
108
+ instance.instance_exec(event, &block)
109
+ rescue StandardError => e
110
+ Protege.configuration.logger.error(
111
+ "[Protege] hook #{hook_class} on #{event_class} failed: #{e.class}: #{e.message}"
112
+ )
113
+ end
114
+
115
+ evict(hook_class, event) if hook_class.stateful? && TERMINAL_EVENTS.include?(event_class)
116
+ end
117
+
118
+ # The instance to run handlers on: fresh per event for stateless hooks, or the run's shared
119
+ # instance (keyed by +correlation_id+) for stateful hooks.
120
+ #
121
+ # @param hook_class [Class] the hook class
122
+ # @param event [Protege::Event] the event being dispatched
123
+ # @return [Object] the hook instance
124
+ def instance_for(hook_class, event)
125
+ return hook_class.new unless hook_class.stateful?
126
+
127
+ @instances_mutex.synchronize { @instances[[hook_class, event.correlation_id]] ||= hook_class.new }
128
+ end
129
+
130
+ # Drop a stateful hook's instance for a finished run.
131
+ #
132
+ # @param hook_class [Class] the hook class
133
+ # @param event [Protege::Event] the terminal event
134
+ # @return [void]
135
+ def evict(hook_class, event)
136
+ @instances_mutex.synchronize { @instances.delete([hook_class, event.correlation_id]) }
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ module Subscribers
5
+ # The tracing subscriber — the one place that knows the +Protege::Trace+ table exists. Subscribes to
6
+ # +InferenceGeneratedEvent+ (emitted once per turn by the Harness, only when +config.tracing+ is on)
7
+ # and snapshots the turn into a durable, self-contained row for later fine-tuning. A peer of
8
+ # +Subscribers::HookDispatcher+ and +Subscribers::IntrospectionBroadcaster+, installed at boot.
9
+ #
10
+ # Kept deliberately decoupled: the Harness gates *whether* the event fires; this module gates nothing
11
+ # and simply persists what arrives. Delete the +install!+ call and tracing disappears with the
12
+ # Harness untouched. Writing is **best-effort** — any failure is logged and swallowed, never
13
+ # propagated, so a tracing hiccup can't abort an inference run (the reply may already have been sent).
14
+ #
15
+ # A trace records only what affects training: the wire +request+ and +response+ (each already
16
+ # serialized by the value object's own +#to_wire+, the same bytes the API exchange uses) and the
17
+ # inference settings (+model+ + +settings+). No persona/provider identity is stored, and this module
18
+ # does no serialization of its own — it just persists what the event carries.
19
+ module Tracing
20
+ extend self
21
+
22
+ # The subscription handle, retained so {reset!} can detach it.
23
+ @handle = nil
24
+
25
+ # Subscribe the tracing recorder to the per-turn event — the subscriber's +install!+ contract.
26
+ # Idempotent; called once at boot from the engine's +after_initialize+.
27
+ #
28
+ # @return [void]
29
+ def install!
30
+ return if @handle
31
+
32
+ @handle = InferenceGeneratedEvent.subscribe { |event| record(event) }
33
+ end
34
+
35
+ # Detach the subscription — the subscriber's +reset!+ contract, for boot re-wiring and tests.
36
+ #
37
+ # @return [void]
38
+ def reset!
39
+ return unless @handle
40
+
41
+ ActiveSupport::Notifications.unsubscribe(@handle)
42
+ @handle = nil
43
+ end
44
+
45
+ private
46
+
47
+ # Snapshot one generated turn into a +Trace+ row. Best-effort: logs and swallows any error.
48
+ #
49
+ # @param event [Protege::InferenceGeneratedEvent] the per-turn event
50
+ # @return [void]
51
+ def record(event)
52
+ Trace.record(
53
+ correlation_id: event.correlation_id,
54
+ turn_index: event.turn,
55
+ model: event.model,
56
+ settings: event.settings,
57
+ request: event.request,
58
+ response: event.response
59
+ )
60
+ rescue StandardError => e
61
+ Protege.configuration.logger.error("[Protege] failed to record trace: #{e.class}: #{e.message}")
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # One document-typed message part — a sibling of +TextPart+ for multimodal content, used for PDFs.
5
+ # Carries the filename, MIME type, and Base64-encoded bytes; the provider renders it into the wire
6
+ # format its API expects (e.g. an OpenAI +file+ block). Document-capable models read the PDF
7
+ # itself — text and layout — rather than a server-side text extraction.
8
+ DocumentPart = Data.define(
9
+ :filename,
10
+ :media_type,
11
+ :data
12
+ )
13
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # One image-typed message part — a sibling of +TextPart+ for multimodal content. Carries the
5
+ # image's MIME type and its Base64-encoded bytes; the provider renders it into the wire format its
6
+ # API expects (e.g. an OpenAI +image_url+ data URI). Vision-capable models read it as an image.
7
+ ImagePart = Data.define(
8
+ :media_type,
9
+ :data
10
+ )
11
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # One normalized message in a chat-completions conversation — a +role+ plus its +content+ (one or more
5
+ # content parts) and optional tool correlation. This is the pipeline-wide currency the model layer
6
+ # speaks: providers serialize it to the wire (+Inference::Provider::Request#to_wire+), the harness
7
+ # threads it through a run, and resolvers build it (via +Resolver#message+) to contribute context. It
8
+ # lives here at the top level beside the content parts it carries (+TextPart+ / +ImagePart+ /
9
+ # +DocumentPart+) rather than inside +Inference+, because — like them — it is ownerless currency every
10
+ # layer constructs and reads, not any one layer's private type. (It cannot be +Protege::Message+: that
11
+ # name is the stored-email ActiveRecord model.)
12
+ #
13
+ # +role+ and +content+ are always required. The tool-correlation fields are optional: +tool_call_id+
14
+ # is meaningful only when +role+ is +:tool+ (it correlates a tool result back to the call);
15
+ # +tool_calls+ is meaningful only when +role+ is +:assistant+ and the model is requesting tool
16
+ # execution.
17
+ ModelMessage = Data.define(
18
+ :role,
19
+ :content,
20
+ :tool_call_id,
21
+ :tool_calls
22
+ ) do
23
+ # @param role [Symbol] one of +:system+, +:user+, +:assistant+, +:tool+.
24
+ # @param content [String, Array<TextPart>] message content; String for the text-only case.
25
+ # @param tool_call_id [String, nil] correlates a +role: :tool+ message to its originating call.
26
+ # @param tool_calls [Array<ToolCall>, nil] tool calls the model requested (assistant only).
27
+ def initialize(role:, content:, tool_call_id: nil, tool_calls: nil)
28
+ super
29
+ end
30
+
31
+ # Return content as a normalized parts array.
32
+ #
33
+ # Array content passes through unchanged; String content is wrapped in a single +TextPart+.
34
+ #
35
+ # @return [Array<TextPart>] the content normalized to parts.
36
+ def parts
37
+ content.is_a?(Array) ? content : [TextPart.new(text: content)]
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Immutable outcome value — the standard return type passed between system parts across the LOGI
5
+ # pipeline: tools (+Tool#use+), resolvers, and gateway operations all hand back a +Result+. Carrying
6
+ # success/failure as data (rather than raising across layer boundaries) lets the Orchestrator
7
+ # serialize a failed tool outcome straight into the LLM's tool message so the model can
8
+ # self-correct. Frozen on construction; build via +Result.success+ / +Result.failure+.
9
+ #
10
+ # @example
11
+ # Result.success(id: 42)
12
+ # Result.failure(reason: 'bad input', field: :email)
13
+ # Result.failure(reason: exception)
14
+ class Result
15
+ # Number of backtrace frames retained in the serialized error.
16
+ #
17
+ # The serialized result is encoded into the LLM's tool message, where a deep backtrace is just
18
+ # token-costly noise — the class and message are what the model reasons over. Operators still get
19
+ # the full backtrace via the engine log (see +ToolMixin.invoke+) and the persisted
20
+ # +Message#last_processing_error+.
21
+ SERIALIZED_BACKTRACE_FRAMES = 3
22
+
23
+ # @return [Boolean] true when the operation succeeded
24
+ # @return [Hash] the payload data attached to the outcome
25
+ # @return [Exception, nil] the captured error on failure, otherwise nil
26
+ attr_reader :success, :data, :error
27
+
28
+ # @return [Boolean] alias of +#success+ — true when the operation succeeded
29
+ alias success? success
30
+
31
+ class << self
32
+ # Build a successful Result.
33
+ #
34
+ # @param data [Hash] arbitrary payload keyed by symbol
35
+ # @return [Protege::Result] a frozen successful result
36
+ def success(**data)
37
+ new success: true, data:
38
+ end
39
+
40
+ # Build a failed Result.
41
+ #
42
+ # +reason+ may be a String or an Exception; strings are wrapped in +Protege::GenericError+ so
43
+ # +#error+ is always an Exception.
44
+ #
45
+ # @param reason [String, Exception] the failure cause
46
+ # @param data [Hash] arbitrary payload keyed by symbol
47
+ # @return [Protege::Result] a frozen failed result
48
+ def failure(reason:, **data)
49
+ new success: false, data:, error: wrap_generic_error(reason)
50
+ end
51
+
52
+ private
53
+
54
+ # Coerce a failure reason into an Exception.
55
+ #
56
+ # Non-exception reasons become a +Protege::GenericError+ so the result always carries an
57
+ # error class and message.
58
+ #
59
+ # @param reason [String, Exception] the failure cause
60
+ # @return [Exception] the original exception, or a wrapped generic error
61
+ def wrap_generic_error(reason)
62
+ return reason if reason.is_a?(Exception)
63
+
64
+ Protege::GenericError.new(reason.to_s)
65
+ end
66
+ end
67
+
68
+ # Initialize and freeze the result.
69
+ #
70
+ # @param success [Boolean] whether the operation succeeded
71
+ # @param data [Hash] arbitrary payload keyed by symbol
72
+ # @param error [Exception, nil] the captured error, present only on failure
73
+ # @return [void]
74
+ def initialize(success:, data: {}, error: nil)
75
+ @success = success
76
+ @data = data
77
+ @error = error
78
+
79
+ freeze
80
+ end
81
+
82
+ # @return [Boolean] true when the operation failed
83
+ def failure?
84
+ !success
85
+ end
86
+
87
+ # Serialize the result to a plain hash suitable for JSON encoding into a tool message.
88
+ #
89
+ # @return [Hash] +{ success:, data:, error: }+ where +error+ is a serialized hash or nil
90
+ def to_h
91
+ {
92
+ success:,
93
+ data:,
94
+ error: serialized_error
95
+ }
96
+ end
97
+
98
+ private
99
+
100
+ # Render +#error+ as a transport-safe hash.
101
+ #
102
+ # @return [Hash, nil] +{ message:, class:, backtrace: }+, or nil when there is no error
103
+ def serialized_error
104
+ return nil unless error
105
+
106
+ {
107
+ message: error.message,
108
+ class: error.class.name,
109
+ backtrace: error.backtrace&.first(SERIALIZED_BACKTRACE_FRAMES)
110
+ }
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module Protege
6
+ # Domain wrapper around a stored +ActiveStorage::Blob+ — the single source of truth for how the
7
+ # engine interprets a file: its kind (image / document / text / viewable), a one-line human summary,
8
+ # and how it renders into the shapes other layers speak. It renders itself two ways: +#to_part+
9
+ # produces the provider's multimodal content block (an +ImagePart+ / +DocumentPart+) that inference
10
+ # feeds a vision model, and +#to_mail_attachment+ produces the transport payload the send path
11
+ # attaches to outbound mail. +ReadAttachmentTool+ and +ThreadHistoryResolver+ read its kind and
12
+ # summary. MIME classification (and the parameter-stripping that makes +image/jpeg; name=…+ match)
13
+ # lives here and nowhere else, so provider and tool authors classify files without re-deriving the
14
+ # rules.
15
+ #
16
+ # A file's durable identity is its *blob* — the per-message +ActiveStorage::Attachment+ is just a
17
+ # join that is recreated whenever a file rides on a new message. So this wraps the blob directly:
18
+ # the +#id+ it reports (and the id in +#summary+) is the *blob* id, which is the currency the
19
+ # +read_attachment+, +send_email+, and +create_file+ tools all speak.
20
+ class StoredFile
21
+ extend Forwardable
22
+
23
+ # Content types the vision models accept as inline images (jpeg/png/gif/webp). Other image types
24
+ # (HEIC, BMP, TIFF, SVG) are deliberately excluded — the models can't view them.
25
+ IMAGE_TYPES = %w[image/png image/jpeg image/jpg image/gif image/webp].freeze
26
+
27
+ # Content types sent as a native document block. PDF is the only type the models read as a
28
+ # document today; listed as a set so a newly-supported type is a one-line addition.
29
+ DOCUMENT_TYPES = %w[application/pdf].freeze
30
+
31
+ # Non-+text/*+ content types whose payloads are safe to return as text.
32
+ TEXT_TYPES = %w[application/json application/xml application/csv].freeze
33
+
34
+ class << self
35
+ # Look up a stored file by its blob id, or +nil+ when no blob matches. Nil-safe (uses +find_by+,
36
+ # not +find+), so a tool turns a missing id into a failed +Result+ rather than raising. Single-
37
+ # argument lookup, so it stays positional by convention.
38
+ #
39
+ # @param blob_id [Integer, String] the +ActiveStorage::Blob+ id
40
+ # @return [StoredFile, nil] the wrapped blob, or nil when the id is unknown
41
+ def find(blob_id)
42
+ blob = ActiveStorage::Blob.find_by(id: blob_id)
43
+ blob && new(blob)
44
+ end
45
+ end
46
+
47
+ # @return [ActiveStorage::Blob] the wrapped blob
48
+ attr_reader :blob
49
+
50
+ # Read-through to the wrapped blob for the values consumers need verbatim.
51
+ def_delegators :@blob, :content_type, :byte_size, :id, :download
52
+
53
+ # @param blob [ActiveStorage::Blob] the stored blob to wrap
54
+ def initialize(blob)
55
+ @blob = blob
56
+ end
57
+
58
+ # @return [String] the original filename as a plain string
59
+ def filename
60
+ blob.filename.to_s
61
+ end
62
+
63
+ # Render this file as the provider content part a vision model reads, or +nil+ when its type can't
64
+ # be viewed (video, Office formats, archives). Images become an +ImagePart+, PDFs a +DocumentPart+;
65
+ # the bytes are downloaded and Base64-encoded only when there is a part to build.
66
+ #
67
+ # @return [ImagePart, DocumentPart, nil] the vision part, or nil when unviewable
68
+ def to_part
69
+ if image?
70
+ ImagePart.new(media_type:, data: encoded_bytes)
71
+ elsif document?
72
+ DocumentPart.new(filename:, media_type:, data: encoded_bytes)
73
+ end
74
+ end
75
+
76
+ # Build the outbound mail payload for this file — the typed attachment the send path attaches to a
77
+ # message. Downloads the bytes for transport; the blob itself remains the durable identity.
78
+ #
79
+ # @return [Gateway::Mail::Attachment] the transport payload
80
+ def to_mail_attachment
81
+ Gateway::Mail::Attachment.new(filename:, content_type:, bytes: download)
82
+ end
83
+
84
+ # @return [Boolean] true when the file is an image a vision model can view
85
+ def image?
86
+ IMAGE_TYPES.include?(media_type)
87
+ end
88
+
89
+ # @return [Boolean] true when the file is a document a model can read natively (PDF)
90
+ def document?
91
+ DOCUMENT_TYPES.include?(media_type)
92
+ end
93
+
94
+ # @return [Boolean] true when the file's content can be returned as plain text
95
+ def text?
96
+ media_type.start_with?('text/') || TEXT_TYPES.include?(media_type)
97
+ end
98
+
99
+ # @return [Boolean] true when the file can be shown to the model as a vision part (image or PDF)
100
+ def viewable?
101
+ image? || document?
102
+ end
103
+
104
+ # The bare MIME type, lowercased and stripped of header parameters (e.g.
105
+ # +"image/jpeg; name=p.jpg"+ → +"image/jpeg"+), so classification and part media types match cleanly.
106
+ #
107
+ # @return [String] the bare, lowercased media type
108
+ def media_type
109
+ content_type.to_s.split(';').first.to_s.strip.downcase
110
+ end
111
+
112
+ # A concise one-line description for the model — filename, type, human size, and the blob id the
113
+ # tools accept.
114
+ #
115
+ # @return [String] e.g. +"invoice.pdf (application/pdf, 42 KB, id: 12)"+
116
+ def summary
117
+ "#{filename} (#{content_type}, #{ActiveSupport::NumberHelper.number_to_human_size(byte_size)}, id: #{id})"
118
+ end
119
+
120
+ private
121
+
122
+ # @return [String] the file's bytes, Base64-encoded for a provider data block
123
+ def encoded_bytes
124
+ Base64.strict_encode64(download)
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # One text-typed message part — the general multimodal-content currency shared across the pipeline
5
+ # (providers serialize it, tools and resolvers build it). The text-only common case; its siblings
6
+ # +ImagePart+ and +DocumentPart+ carry the other content kinds.
7
+ TextPart = Data.define(:text)
8
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Pairs a tool call with its outcome. Built by +ToolMixin.invoke+ and consumed by
5
+ # +Inference.extend_request+ to build the next turn's messages — +tool_call+ identifies which call this
6
+ # answers and +result+ carries the +Protege::Result+ the model sees. An immutable +Data+ value object.
7
+ #
8
+ # It belongs to no single layer — it bridges an Inference +Provider::ToolCall+, a top-level +Result+,
9
+ # and Orchestrator's tool dispatch — so it lives here at the top level alongside +Result+ and +Event+.
10
+ #
11
+ # @!attribute [r] tool_call
12
+ # @return [Object] the originating tool call
13
+ # @!attribute [r] result
14
+ # @return [Protege::Result] the outcome of invoking the tool
15
+ ToolResult = Data.define(:tool_call, :result)
16
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Current gem version, following Semantic Versioning.
5
+ VERSION = '0.1.0.alpha.1'
6
+ end
data/lib/protege.rb ADDED
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'protege/version'
4
+ require 'protege/configuration'
5
+ require 'protege/gateway'
6
+ require 'protege/engine'
7
+
8
+ # Root namespace for the Protege engine — an email-native AI agent toolkit that turns a host
9
+ # Rails application into an agentic workspace.
10
+ #
11
+ # Protege is structured around the LOGI pipeline:
12
+ # - **L**oop — scheduled responsibilities that drive personas on a cron tick.
13
+ # - **O**rchestrator — the +Harness+ multi-turn tool loop that runs inference and dispatches tools.
14
+ # - **G**ateway — Action Mailbox inbound delivery and SMTP outbound delivery.
15
+ # - **I**nference — provider adapters that talk to model APIs.
16
+ #
17
+ # Email is the channel, the host Rails app is the workspace, and personas, tools, providers,
18
+ # resolvers, and hooks all participate as extensions. Requiring this file loads only the engine
19
+ # entry points; runtime constructs are resolved through Rails autoloading once the engine boots.
20
+ #
21
+ # @see https://github.com/sebscholl/protege
22
+ module Protege
23
+ extend self
24
+
25
+ # Yield the configuration object for mutation. Call once in a Rails initializer.
26
+ #
27
+ # @yield [config] the configuration to mutate
28
+ # @yieldparam config [Protege::Configuration] the process-wide configuration
29
+ # @return [void]
30
+ def configure
31
+ yield configuration
32
+ end
33
+
34
+ # Return the process-wide configuration, creating it on first access.
35
+ #
36
+ # @return [Protege::Configuration] the memoized configuration instance
37
+ def configuration
38
+ @configuration ||= Configuration.new
39
+ end
40
+
41
+ # Reset configuration to defaults. Primarily for test isolation between examples.
42
+ #
43
+ # @return [Protege::Configuration] the freshly built configuration
44
+ def reset_configuration!
45
+ @configuration = Configuration.new
46
+ end
47
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :protege do
4
+ namespace :css do
5
+ desc "Build the Protege engine's compiled Tailwind CSS. " \
6
+ 'Run this after changing engine views. Requires tailwindcss-rails in the host app.'
7
+ task :build do
8
+ gem_root = File.expand_path('../../..', __dir__)
9
+ input = File.join(gem_root, 'app/assets/tailwind/application.css')
10
+ output = File.join(gem_root, 'app/assets/builds/protege.css')
11
+ content = "#{gem_root}/app/views/**/*.html.erb,#{gem_root}/app/helpers/**/*.rb"
12
+
13
+ sh "tailwindcss -i #{input} -o #{output} --content #{content}"
14
+ end
15
+ end
16
+ end