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,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Presents one live tool call for the console **introspection panel** — a PORO presentation helper for
5
+ # the console experience, not part of the engine's inference core. The panel is an event-driven feed
6
+ # (not a view of persisted rows): as the harness emits +tool_call.started/completed/failed+ events,
7
+ # +IntrospectionBroadcaster+ builds an +IntrospectionToolCallPresentation+ from the event and renders the
8
+ # +tool_call+ partial with it — first as a pending card, then replaced in place with the outcome.
9
+ # Keeping the presentation here (rather than in the partial) keeps the card markup dumb and the
10
+ # formatting unit-testable.
11
+ #
12
+ # Build via the state factories: +.pending+ (call just requested), +.completed+ (tool returned a
13
+ # successful +Result+), +.failed+ (tool returned a failed +Result+, so an error).
14
+ class IntrospectionToolCallPresentation
15
+ # Longest inline preview/args summary before it is clipped or replaced by a size count.
16
+ PREVIEW_LIMIT = 80
17
+
18
+ # Longest error message shown on a failed card before clipping.
19
+ ERROR_PREVIEW_LIMIT = 200
20
+
21
+ class << self
22
+ # A card for a call the model just requested, before its result is known.
23
+ #
24
+ # @param tool_call [Protege::Inference::Provider::ToolCall] the requested call
25
+ # @return [IntrospectionToolCallPresentation] the pending presentation
26
+ def pending(tool_call:)
27
+ new(tool_call:, status: :pending)
28
+ end
29
+
30
+ # A card for a call that returned successfully.
31
+ #
32
+ # @param tool_call [Protege::Inference::Provider::ToolCall] the call
33
+ # @param result [Protege::Result] the successful outcome (its +data+ drives the preview)
34
+ # @return [IntrospectionToolCallPresentation] the completed presentation
35
+ def completed(tool_call:, result:)
36
+ new(tool_call:, status: :done, result:)
37
+ end
38
+
39
+ # A card for a call that failed.
40
+ #
41
+ # @param tool_call [Protege::Inference::Provider::ToolCall] the call
42
+ # @param error [Exception] the failure (its message drives the preview)
43
+ # @return [IntrospectionToolCallPresentation] the failed presentation
44
+ def failed(tool_call:, error:)
45
+ new(tool_call:, status: :failed, error:)
46
+ end
47
+ end
48
+
49
+ # @return [Symbol] the card state — +:pending+, +:done+, or +:failed+
50
+ attr_reader :status
51
+
52
+ # @param tool_call [Protege::Inference::Provider::ToolCall] the call being presented
53
+ # @param status [Symbol] +:pending+, +:done+, or +:failed+
54
+ # @param result [Protege::Result, nil] the successful outcome, when done
55
+ # @param error [Exception, nil] the failure, when failed
56
+ def initialize(tool_call:, status:, result: nil, error: nil)
57
+ @tool_call = tool_call
58
+ @status = status
59
+ @result = result
60
+ @error = error
61
+ end
62
+
63
+ # Stable DOM id for the card, so the pending card can be replaced in place by its outcome.
64
+ #
65
+ # @return [String] the card's DOM id
66
+ def card_id
67
+ "tool_call_#{@tool_call.id}"
68
+ end
69
+
70
+ # @return [String] the tool's name
71
+ def tool_name
72
+ @tool_call.name
73
+ end
74
+
75
+ # A one-line rendering of the call's arguments, clipped for length.
76
+ #
77
+ # @return [String, nil] the args summary, or nil when there are none
78
+ def arguments_summary
79
+ input = @tool_call.input
80
+ return nil if input.blank?
81
+
82
+ clip(input.map { |key, value| "#{key}: #{value.inspect}" }.join(', '), limit: PREVIEW_LIMIT)
83
+ end
84
+
85
+ # The response line: a short rendering of the result on success (or a size count when large), the
86
+ # error message on failure, and nothing while pending.
87
+ #
88
+ # @return [String, nil] the preview, or nil when pending / empty
89
+ def preview
90
+ case status
91
+ when :failed then clip(@error&.message.to_s, limit: ERROR_PREVIEW_LIMIT)
92
+ when :done then result_preview
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ # Render the successful result's data as a short string, or its character count when too long to
99
+ # show inline.
100
+ #
101
+ # @return [String, nil] the response preview, or nil when there is no data
102
+ def result_preview
103
+ rendered = (@result&.data || {}).map { |key, value| "#{key}: #{preview_value(value)}" }.join(', ')
104
+
105
+ return nil if rendered.empty?
106
+ return rendered if rendered.length <= PREVIEW_LIMIT
107
+
108
+ "#{ActiveSupport::NumberHelper.number_to_delimited(rendered.length)} chars"
109
+ end
110
+
111
+ # A compact stand-in for a single result value — collections become a count, scalars their string.
112
+ #
113
+ # @param value [Object] the value to render
114
+ # @return [String] the compact rendering
115
+ def preview_value(value)
116
+ case value
117
+ when Array then "#{value.size} items"
118
+ when Hash then "#{value.size} keys"
119
+ else value.to_s
120
+ end
121
+ end
122
+
123
+ # Clip a string to +limit+ characters, appending an ellipsis when cut.
124
+ #
125
+ # @param text [String] the text to clip
126
+ # @param limit [Integer] the maximum length
127
+ # @return [String] the original or a clipped copy
128
+ def clip(text, limit:)
129
+ return text if text.length <= limit
130
+
131
+ "#{text[0, limit]}…"
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Cross-persona email search behind the console's search page — a PORO service object (not an
5
+ # ActiveRecord model, and not the persona-scoped +search_emails+ agent tool).
6
+ #
7
+ # Each field narrows the +Message+ relation: +from+ scans the sender, +recipients+ spans the
8
+ # to/cc/bcc lists, +subject+ and +body+ (text + html) scan their columns, and +attachments+ matches
9
+ # by stored filename. Filled fields are ANDed onto the *same* message row, so a message only matches
10
+ # when it satisfies every supplied field at once. +#results+ returns the matching messages
11
+ # themselves (newest sent first) — the console lists the emails and links each to its thread — rather
12
+ # than collapsing to threads, so the result grain matches the email-grained query.
13
+ #
14
+ # Matching mirrors +search_emails+: case-insensitive +LIKE+ with +sanitize_sql_like+ so the term's
15
+ # own +%+/+_+ are literals — SQLite-safe, which is the dashboard's database.
16
+ class MessageSearch
17
+ # Maps each column-backed search field to the message columns it scans. Columns are a fixed
18
+ # allowlist, so they are safe to interpolate into the +LIKE+ clause (the term is a bound param).
19
+ FIELD_COLUMNS = {
20
+ from: %w[from_address],
21
+ recipients: %w[to_addresses cc_addresses bcc_addresses],
22
+ subject: %w[subject],
23
+ body: %w[text_body html_body]
24
+ }.freeze
25
+
26
+ class << self
27
+ # Build a search from request params, reading the field keys it understands.
28
+ #
29
+ # @param params [ActionController::Parameters, Hash] the request params
30
+ # @return [Protege::MessageSearch] the configured search
31
+ def from_params(params)
32
+ new(from: params[:from],
33
+ recipients: params[:recipients],
34
+ subject: params[:subject],
35
+ body: params[:body],
36
+ attachments: params[:attachments])
37
+ end
38
+ end
39
+
40
+ # @param from [String, nil] sender term
41
+ # @param recipients [String, nil] term matched against to/cc/bcc
42
+ # @param subject [String, nil] subject term
43
+ # @param body [String, nil] term matched against the text and html bodies
44
+ # @param attachments [String, nil] attachment-filename term
45
+ def initialize(from: nil, recipients: nil, subject: nil, body: nil, attachments: nil)
46
+ @column_terms = { from:, recipients:, subject:, body: }
47
+ @attachments = attachments
48
+ end
49
+
50
+ # Whether any field carries a term to search on.
51
+ #
52
+ # @return [Boolean]
53
+ def active?
54
+ (@column_terms.values + [@attachments]).any?(&:present?)
55
+ end
56
+
57
+ # The matching messages, newest sent first, each with its thread preloaded for the result row;
58
+ # empty when no field is filled.
59
+ #
60
+ # +preload+ (a separate query) rather than +includes+ avoids a JOIN — the search columns +subject+
61
+ # and +from_address+ also exist on +protege_email_threads+, so a join would make them ambiguous.
62
+ #
63
+ # @return [ActiveRecord::Relation<Protege::Message>]
64
+ def results
65
+ return Message.none unless active?
66
+
67
+ message_scope.preload(:email_thread).order(sent_at: :desc)
68
+ end
69
+
70
+ private
71
+
72
+ # The +Message+ relation narrowed by every filled field (each ANDed onto the same row).
73
+ #
74
+ # @return [ActiveRecord::Relation<Protege::Message>] the narrowed relation
75
+ def message_scope
76
+ scope = FIELD_COLUMNS.reduce(Message.all) do |relation, (field, columns)|
77
+ term = @column_terms[field]
78
+ next relation if term.blank?
79
+
80
+ relation.where(message_column_match_fragment(columns), term: like_term(term))
81
+ end
82
+
83
+ @attachments.present? ? scope.where(id: attachment_message_ids) : scope
84
+ end
85
+
86
+ # The SQL fragment for a single field's columns, ORed together, with a bound +:term+ param. The
87
+ # caller must supply the term value in the +where+ call.
88
+ #
89
+ # @param columns [Array<String>] the message columns to scan for the field
90
+ # @return [String] the SQL fragment, e.g. "LOWER(subject) LIKE :term OR LOWER(body) LIKE :term"
91
+ def message_column_match_fragment(columns)
92
+ columns.map { "LOWER(#{_1}) LIKE :term" }.join(' OR ')
93
+ end
94
+
95
+ # Message ids that own an attachment whose stored filename matches the attachments term.
96
+ #
97
+ # @return [ActiveRecord::Relation<Integer>] the matching message ids
98
+ def attachment_message_ids
99
+ ActiveStorage::Attachment
100
+ .where(record_type: 'Protege::Message', name: 'attachments')
101
+ .joins(:blob)
102
+ .where('LOWER(active_storage_blobs.filename) LIKE :term', term: like_term(@attachments))
103
+ .select(:record_id)
104
+ end
105
+
106
+ # Wrap a term as a case-insensitive, wildcard-escaped +LIKE+ pattern.
107
+ #
108
+ # @param value [String] the raw search term
109
+ # @return [String] the +%term%+ pattern, lowercased and escaped
110
+ def like_term(value)
111
+ "%#{ActiveRecord::Base.sanitize_sql_like(value.strip.downcase)}%"
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Console-side search over captured inference traces (see +Protege::Trace+) — a PORO service, the trace
5
+ # analog of +MessageSearch+.
6
+ #
7
+ # Each free-text field narrows the +Trace+ relation with a case-insensitive +LIKE+; +label+ filters by
8
+ # the review verdict. Filled fields are ANDed onto the *same* row, so a trace matches only when it
9
+ # satisfies every supplied field at once. +request+/+response+ are JSON-text columns, so those terms
10
+ # match against the serialized JSON of the turn (the prompt/tool catalog and the produced output),
11
+ # not structured fields.
12
+ #
13
+ # Matching mirrors +MessageSearch+: case-insensitive +LIKE+ with +sanitize_sql_like+ so the term's own
14
+ # +%+/+_+ are literals — SQLite-safe, the dashboard's database.
15
+ class TraceSearch
16
+ # Maps each free-text search field to the trace columns it scans. Columns are a fixed allowlist, so
17
+ # they are safe to interpolate into the +LIKE+ clause (the term is always a bound param).
18
+ FIELD_COLUMNS = {
19
+ correlation_id: %w[correlation_id],
20
+ model: %w[model],
21
+ request: %w[request],
22
+ response: %w[response],
23
+ annotation: %w[annotation]
24
+ }.freeze
25
+
26
+ class << self
27
+ # Build a search from request params, reading the field keys it understands.
28
+ #
29
+ # @param params [ActionController::Parameters, Hash] the request params
30
+ # @return [Protege::TraceSearch] the configured search
31
+ def from_params(params)
32
+ new(correlation_id: params[:correlation_id],
33
+ model: params[:model],
34
+ request: params[:request],
35
+ response: params[:response],
36
+ annotation: params[:annotation],
37
+ label: params[:label])
38
+ end
39
+ end
40
+
41
+ # @param correlation_id [String, nil] run-id term
42
+ # @param model [String, nil] model term
43
+ # @param request [String, nil] term matched against the serialized request JSON
44
+ # @param response [String, nil] term matched against the serialized response JSON
45
+ # @param annotation [String, nil] reviewer-note term
46
+ # @param label [String, nil] a review verdict to filter by (ignored unless a real +Trace+ label)
47
+ def initialize(correlation_id: nil, model: nil, request: nil, response: nil, annotation: nil, label: nil)
48
+ @column_terms = { correlation_id:, model:, request:, response:, annotation: }
49
+ @label = label if Trace.labels.key?(label)
50
+ end
51
+
52
+ # Whether any field carries something to search on — a text term or a valid label.
53
+ #
54
+ # @return [Boolean]
55
+ def active?
56
+ @column_terms.values.any?(&:present?) || @label.present?
57
+ end
58
+
59
+ # The matching traces, newest captured first; empty when nothing is filled.
60
+ #
61
+ # @return [ActiveRecord::Relation<Protege::Trace>]
62
+ def results
63
+ return Trace.none unless active?
64
+
65
+ trace_scope.order(created_at: :desc)
66
+ end
67
+
68
+ private
69
+
70
+ # The +Trace+ relation narrowed by every filled text field (each ANDed onto the same row), then by
71
+ # the label verdict when one is set.
72
+ #
73
+ # @return [ActiveRecord::Relation<Protege::Trace>] the narrowed relation
74
+ def trace_scope
75
+ scope = FIELD_COLUMNS.reduce(Trace.all) do |relation, (field, columns)|
76
+ term = @column_terms[field]
77
+ next relation if term.blank?
78
+
79
+ relation.where(column_match_fragment(columns), term: like_term(term))
80
+ end
81
+
82
+ @label.present? ? scope.where(label: @label) : scope
83
+ end
84
+
85
+ # The SQL fragment for a field's columns, ORed together, with a bound +:term+ param. The caller
86
+ # supplies the term value in the +where+ call.
87
+ #
88
+ # @param columns [Array<String>] the trace columns to scan for the field
89
+ # @return [String] the SQL fragment, e.g. "LOWER(request) LIKE :term"
90
+ def column_match_fragment(columns)
91
+ columns.map { "LOWER(#{_1}) LIKE :term" }.join(' OR ')
92
+ end
93
+
94
+ # Wrap a term as a case-insensitive, wildcard-escaped +LIKE+ pattern.
95
+ #
96
+ # @param value [String] the raw search term
97
+ # @return [String] the +%term%+ pattern, lowercased and escaped
98
+ def like_term(value)
99
+ "%#{ActiveRecord::Base.sanitize_sql_like(value.strip.downcase)}%"
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Protege
4
+ # Drives the console's **introspection panel** from inference events — an app-level subscriber for the
5
+ # console experience, not part of the engine's inference core. A peer of +Subscribers::HookDispatcher+
6
+ # and +Subscribers::Tracing+, installed once at boot.
7
+ #
8
+ # It subscribes to Protege events and translates streaming + tool-call notifications into Turbo Stream
9
+ # broadcasts, building the panel's live activity feed: one chronological stream where the model's
10
+ # reasoning tokens and its tool calls are interleaved in the order they happen. Installed via the
11
+ # +install!+ contract (see the engine initializer) and holds process-local state for the lifetime of
12
+ # the process; +reset!+ detaches its subscriptions and clears that state.
13
+ #
14
+ # What each event does (all broadcast to the thread's Turbo channel, target +dom_id(thread, :activity)+
15
+ # — the feed container in +_introspection.html.slim+):
16
+ #
17
+ # * +InferenceChunkEvent+ — stream the token into the run's current reasoning block, opening a fresh block
18
+ # after each tool call so reasoning is grouped into spaced paragraphs. Interleaving is automatic:
19
+ # every append lands at the feed's end, so reasoning blocks and cards stay in chronological order.
20
+ # * +ToolCallStartedEvent+ — append a pending tool card the instant the model requests the call.
21
+ # * +ToolCallCompletedEvent+ / +ToolCallFailedEvent+ — replace that card in place with the outcome
22
+ # (a response preview, or the error message).
23
+ #
24
+ # **Routing.** Only +InferenceChunkEvent+ carries the thread; the tool-call events carry just the
25
+ # +correlation_id+ that +Protege::Event.emit+ injects into every event. So the broadcaster keeps a
26
+ # +correlation_id → thread+ map, populated from +InferenceStartedEvent+ (which carries the message, hence
27
+ # its thread) and cleared on +InferenceCompletedEvent+/+InferenceFailedEvent+. This is the single stream-
28
+ # routing convention — no +emit_*+ signature carries the thread just for the panel. +@mutex+ guards
29
+ # the map because callbacks may run concurrently across threads. Runs with no thread (scheduled
30
+ # +ResponsibilityRun+s) never populate the map, so their tool events broadcast nothing.
31
+ #
32
+ # The feed is live-only by design: it is not rehydrated from persistence, so a page reload shows an
33
+ # empty feed until the next run streams in.
34
+ module IntrospectionBroadcaster
35
+ # Maps a run's +correlation_id+ to the +EmailThread+ its events broadcast to. Populated on
36
+ # +InferenceStartedEvent+, cleared on completion/failure.
37
+ @threads = {}
38
+
39
+ # Maps a run's +correlation_id+ to its currently open reasoning block's DOM id (tokens append into
40
+ # it), or nil when none is open — a tool call closes the block so the next token opens a fresh one.
41
+ @reasoning = {}
42
+
43
+ # Maps a run's +correlation_id+ to a counter that makes each reasoning block's DOM id unique.
44
+ @reasoning_seq = {}
45
+
46
+ # Live event-bus subscription handles, retained so {reset!} can detach them.
47
+ @handles = []
48
+
49
+ # Guards concurrent access to the maps from callbacks running on multiple threads.
50
+ @mutex = Mutex.new
51
+
52
+ extend self
53
+
54
+ # Wire the event listeners — the subscriber's +install!+ contract. Idempotent: retains the
55
+ # subscription handles and no-ops until {reset!} runs, so a repeat call never double-subscribes.
56
+ # Called once at boot from the engine's +after_initialize+.
57
+ #
58
+ # @return [void]
59
+ def install!
60
+ return unless @handles.empty?
61
+
62
+ @handles = [
63
+ install_start_listener,
64
+ install_chunk_listener,
65
+ *install_tool_listeners,
66
+ *install_completion_listeners
67
+ ]
68
+ end
69
+
70
+ # Detach every subscription and drop all per-run state — the subscriber's +reset!+ contract, for
71
+ # boot re-wiring and tests.
72
+ #
73
+ # @return [void]
74
+ def reset!
75
+ @handles.each { |handle| ActiveSupport::Notifications.unsubscribe(handle) }
76
+ @handles = []
77
+ @mutex.synchronize do
78
+ @threads.clear
79
+ @reasoning.clear
80
+ @reasoning_seq.clear
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ # Remember which thread a run's later (thread-less) events belong to, and open the run in the feed
87
+ # with a pulsing "Processing…" marker.
88
+ #
89
+ # @return [Object] the subscription handle
90
+ def install_start_listener
91
+ InferenceStartedEvent.subscribe do |event|
92
+ thread = event.message&.email_thread
93
+ next unless thread
94
+
95
+ # The correlation_id is the run's stable DOM id, so we can key the reasoning block and the marker on it.
96
+ remember(correlation_id: event.correlation_id, thread:)
97
+
98
+ broadcast_run_marker(thread:,
99
+ correlation_id: event.correlation_id,
100
+ status: :processing,
101
+ action: :append)
102
+ end
103
+ end
104
+
105
+ # Append each streamed reasoning token to the run's current reasoning block. Chunks carry the thread
106
+ # directly; empty content and thread-less payloads are dropped.
107
+ #
108
+ # @return [Object] the subscription handle
109
+ def install_chunk_listener
110
+ InferenceChunkEvent.subscribe do |event|
111
+ thread = event.thread
112
+ content = event.chunk_content
113
+ next if thread.nil? || content.nil? || content.empty?
114
+
115
+ append_reasoning(thread:,
116
+ correlation_id: event.correlation_id,
117
+ content:)
118
+ end
119
+ end
120
+
121
+ # Stream the tool-call lifecycle into the feed: a pending card on start, then the outcome in place.
122
+ # Starting a call also closes the run's open reasoning block, so post-tool reasoning opens a new one.
123
+ #
124
+ # @return [Array<Object>] the subscription handles
125
+ def install_tool_listeners
126
+ started = ToolCallStartedEvent.subscribe do |event|
127
+ close_reasoning(event.correlation_id)
128
+ presenter = IntrospectionToolCallPresentation.pending(tool_call: event.tool_call)
129
+ broadcast_card(event, presenter, :append)
130
+ end
131
+
132
+ completed = ToolCallCompletedEvent.subscribe do |event|
133
+ presenter = IntrospectionToolCallPresentation.completed(tool_call: event.tool_call,
134
+ result: event.result)
135
+ broadcast_card(event, presenter, :replace)
136
+ end
137
+
138
+ failed = ToolCallFailedEvent.subscribe do |event|
139
+ presenter = IntrospectionToolCallPresentation.failed(tool_call: event.tool_call, error: event.error)
140
+ broadcast_card(event, presenter, :replace)
141
+ end
142
+
143
+ [started, completed, failed]
144
+ end
145
+
146
+ # Close the run in the feed — remove its marker on success, replace it with a red error on failure
147
+ # — and forget its thread.
148
+ #
149
+ # @return [Array<Object>] the subscription handles
150
+ def install_completion_listeners
151
+ completed = InferenceCompletedEvent.subscribe do |event|
152
+ finish_run(correlation_id: event.correlation_id)
153
+ end
154
+
155
+ failed = InferenceFailedEvent.subscribe do |event|
156
+ finish_run(correlation_id: event.correlation_id, error: event.error)
157
+ end
158
+
159
+ [completed, failed]
160
+ end
161
+
162
+ # Resolve the run's marker (remove it when the run succeeded, replace it with the error when it
163
+ # failed), then drop its thread mapping.
164
+ #
165
+ # @param correlation_id [String, nil] the run's id
166
+ # @param error [Exception, nil] the failure, or nil on success
167
+ # @return [void]
168
+ def finish_run(correlation_id:, error: nil)
169
+ thread = thread_for(correlation_id)
170
+
171
+ if thread && error
172
+ broadcast_run_marker(thread:,
173
+ correlation_id:,
174
+ status: :failed,
175
+ detail: error.message,
176
+ action: :replace)
177
+ elsif thread
178
+ Turbo::StreamsChannel.broadcast_remove_to(thread, target: run_marker_id(correlation_id))
179
+ end
180
+
181
+ forget(correlation_id:)
182
+ end
183
+
184
+ # Append (on start) or replace (on finish) a run's lifecycle marker in the feed.
185
+ #
186
+ # @param thread [EmailThread] the run's thread
187
+ # @param correlation_id [String] the run's id (its marker's stable DOM id)
188
+ # @param status [Symbol] +:processing+ or +:failed+
189
+ # @param action [Symbol] +:append+ (start) or +:replace+ (finish)
190
+ # @param detail [String, nil] the error message, for +:failed+
191
+ # @return [void]
192
+ def broadcast_run_marker(thread:, correlation_id:, status:, action:, detail: nil)
193
+ id = run_marker_id(correlation_id)
194
+ locals = { id:, status:, detail: }
195
+ partial = 'protege/threads/run_marker'
196
+
197
+ if action == :append
198
+ Turbo::StreamsChannel.broadcast_append_to(thread, target: activity_target(thread), partial:, locals:)
199
+ else
200
+ Turbo::StreamsChannel.broadcast_replace_to(thread, target: id, partial:, locals:)
201
+ end
202
+ end
203
+
204
+ # Stream a reasoning token: open a fresh reasoning block in the feed if none is open for this run
205
+ # (carrying the first token), otherwise append the token into the open block.
206
+ #
207
+ # @param thread [EmailThread] the thread whose feed to update
208
+ # @param correlation_id [String] the run's id (keys its open reasoning block)
209
+ # @param content [String] the raw token text
210
+ # @return [void]
211
+ def append_reasoning(thread:, correlation_id:, content:)
212
+ block_id, opened = reasoning_block(correlation_id)
213
+
214
+ if opened
215
+ Turbo::StreamsChannel.broadcast_append_to(thread,
216
+ target: activity_target(thread),
217
+ partial: 'protege/threads/reasoning',
218
+ locals: { id: block_id, content: })
219
+ else
220
+ html = ERB::Util.html_escape(content)
221
+ Turbo::StreamsChannel.broadcast_append_to(thread, target: block_id, html:)
222
+ end
223
+ end
224
+
225
+ # The next sequence number for a run's reasoning blocks, so each block's DOM id is unique within the run.
226
+ #
227
+ # @param correlation_id [String] the run's id
228
+ # @return [Integer] the next sequence number
229
+ def reasoning_block_id(correlation_id)
230
+ (@reasoning_seq[correlation_id] || 0) + 1
231
+ end
232
+
233
+ # The run's open reasoning block, opening a new one when none is active.
234
+ #
235
+ # @param correlation_id [String] the run's id
236
+ # @return [Array(String, Boolean)] the block's DOM id and whether it was just opened
237
+ def reasoning_block(correlation_id)
238
+ @mutex.synchronize do
239
+ existing = @reasoning[correlation_id]
240
+ next [existing, false] if existing
241
+
242
+ reasoning_block_id(correlation_id).tap do |seq|
243
+ @reasoning_seq[correlation_id] = seq
244
+ @reasoning[correlation_id] = "reasoning_#{correlation_id}_#{seq}"
245
+ end
246
+
247
+ [@reasoning[correlation_id], true]
248
+ end
249
+ end
250
+
251
+ # Close the run's open reasoning block, so the next token opens a fresh one.
252
+ #
253
+ # @param correlation_id [String] the run's id
254
+ # @return [void]
255
+ def close_reasoning(correlation_id)
256
+ @mutex.synchronize { @reasoning.delete(correlation_id) }
257
+ end
258
+
259
+ # Broadcast a tool card, appending it (pending) or replacing it in place (outcome). No-op when the
260
+ # event's run has no mapped thread.
261
+ #
262
+ # @param event [Protege::Event] the tool-call event
263
+ # @param card [IntrospectionToolCallPresentation] the card to render
264
+ # @param action [Symbol] +:append+ (pending) or +:replace+ (outcome)
265
+ # @return [void]
266
+ def broadcast_card(event, card, action)
267
+ thread = thread_for(event.correlation_id)
268
+ return unless thread
269
+
270
+ locals = { presentation: card }
271
+ partial = 'protege/threads/tool_call'
272
+
273
+ if action == :append
274
+ Turbo::StreamsChannel.broadcast_append_to(thread,
275
+ target: activity_target(thread),
276
+ partial:, locals:)
277
+ else
278
+ Turbo::StreamsChannel.broadcast_replace_to(thread,
279
+ target: card.card_id,
280
+ partial:,
281
+ locals:)
282
+ end
283
+ end
284
+
285
+ # The feed container's DOM id for a thread (matches +_introspection.html.slim+).
286
+ #
287
+ # @param thread [EmailThread] the thread
288
+ # @return [String] the activity feed target id
289
+ def activity_target(thread)
290
+ ActionView::RecordIdentifier.dom_id(thread, :activity)
291
+ end
292
+
293
+ # The stable DOM id for a run's lifecycle marker.
294
+ #
295
+ # @param correlation_id [String] the run's id
296
+ # @return [String] the marker's DOM id
297
+ def run_marker_id(correlation_id)
298
+ "run_#{correlation_id}"
299
+ end
300
+
301
+ # @param correlation_id [String] the run's id
302
+ # @param thread [EmailThread] the run's thread
303
+ # @return [void]
304
+ def remember(correlation_id:, thread:)
305
+ @mutex.synchronize { @threads[correlation_id] = thread }
306
+ end
307
+
308
+ # @param correlation_id [String, nil] the run's id
309
+ # @return [EmailThread, nil] the mapped thread, or nil when the run has none
310
+ def thread_for(correlation_id)
311
+ @mutex.synchronize { @threads[correlation_id] }
312
+ end
313
+
314
+ # @param correlation_id [String, nil] the run's id
315
+ # @return [void]
316
+ def forget(correlation_id:)
317
+ @mutex.synchronize do
318
+ @threads.delete(correlation_id)
319
+ @reasoning.delete(correlation_id)
320
+ @reasoning_seq.delete(correlation_id)
321
+ end
322
+ end
323
+ end
324
+ end