omen 0.4.0 → 0.6.0

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.
@@ -1,4 +1,4 @@
1
- # Everything Claude is told before a question: the prose, the schema, and the shape of a reply.
1
+ # Everything Claude is told before a question: the prose, and the schema it may write against.
2
2
  class Omen::Instructions
3
3
  # The prose, kept beside this class so it reads as prose rather than as a string.
4
4
  PROSE = File.expand_path 'instructions.md', __dir__
@@ -13,28 +13,16 @@ class Omen::Instructions
13
13
  # The database function a distance in miles is measured with, created by the same task.
14
14
  MILES = 'omen_miles_between'
15
15
 
16
- # The one shape a reply may take: both keys required, and no others admitted.
17
- ANSWER = {
18
- type: 'object', additionalProperties: false, required: %w[ sql note combine ],
19
- properties: {
20
- sql: { type: 'string',
21
- description: 'The one PostgreSQL SELECT that answers the question, or ' \
22
- 'empty to ask something first.', },
23
- note: { type: 'string',
24
- description: 'A sentence or two: what the query returns and any ' \
25
- 'assumption made. If sql is empty, the question you ' \
26
- 'need answered first.', },
27
- combine: Omen::Combination::SCHEMA,
28
- },
29
- }
30
-
31
- # @return [Hash] what a reply is constrained to, so that it always parses.
32
- def self.output_config = { format_: { type: :json_schema, schema: ANSWER } }
33
-
34
16
  # Cached for an hour: the schema is thousands of tokens, and refining sends it again.
17
+ # @param reading [Omen::Reading] the one being answered, which says what it may read.
35
18
  # @return [Array<Hash>] the one system block of a request.
36
- def self.block
37
- [ { type: 'text', text: new.text, cache_control: { type: 'ephemeral', ttl: '1h' } } ]
19
+ def self.block(reading)
20
+ [ { type: 'text', text: new(reading).text, cache_control: { type: 'ephemeral', ttl: '1h' } } ]
21
+ end
22
+
23
+ # @param reading [Omen::Reading] the one being answered, which says what it may read.
24
+ def initialize(reading)
25
+ @reading = reading
38
26
  end
39
27
 
40
28
  # Today's date is said out loud because "last month" is Claude's to resolve, and it has no clock.
@@ -43,17 +31,20 @@ class Omen::Instructions
43
31
  format File.read(PROSE), today: Date.current.to_fs(:long), zone_fn: TIME_ZONE,
44
32
  today_fn: TODAY, miles_fn: MILES,
45
33
  schema: schema, types: types, readable: readable, refused: refused,
46
- notes: Omen.config.notes
34
+ notes: @reading.notes
47
35
  end
48
36
 
49
37
  private
50
38
 
51
- def schema = Omen::Schema.new.text
39
+ def schema = Omen::Schema.new(@reading.runs_as).text
52
40
 
41
+ # This gem's own tables are told apart by a type column too, and are hidden from the schema,
42
+ # so naming their subclasses here would say the one thing the schema is careful not to.
53
43
  def types
54
44
  Rails.application.eager_load!
55
45
  Omen.config.record.descendants.select(&:finder_needs_type_condition?)
56
- .group_by(&:table_name).sort.map { |table, kinds| "- `#{table}`: #{named kinds}" }.join "\n"
46
+ .group_by(&:table_name).except(*Omen.tables).sort
47
+ .map { |table, kinds| "- `#{table}`: #{named kinds}" }.join "\n"
57
48
  end
58
49
 
59
50
  def named(kinds) = kinds.map(&:name).sort.map { |name| "`#{name}`" }.join ', '
@@ -0,0 +1,35 @@
1
+ # Extends Omen::Reading with the audience it is asked by: what that audience may read, and the
2
+ # Postgres role that holds it there.
3
+ module Omen::Narrowed extend ActiveSupport::Concern
4
+ included do
5
+ # What this kind of reading is held to, where a host declared one.
6
+ class_attribute :narrowing
7
+ end
8
+
9
+ class_methods do
10
+ # Declares what a reading of this kind may read, which db:omen:grant then writes.
11
+ # @param role [String] the Postgres role its statement runs as.
12
+ # @param by [Symbol] the column of the reading that says whose rows these are.
13
+ # @param own [Hash] every table it reads rows of, to what makes a row its own -- written
14
+ # with `%{owner}`, which stands for whoever the reading names.
15
+ # @param whole [Array<String>] the tables it reads whole, nobody's in particular.
16
+ # @param except [Regexp, nil] the columns of those it may not read.
17
+ # @return [void]
18
+ def narrows(role, by:, own:, whole: [], except: nil)
19
+ self.narrowing = Omen::Narrowing.new role: role, by: by, own: own, whole: whole,
20
+ except: except
21
+ end
22
+
23
+ # @return [Array<Omen::Narrowing>] every one a host has declared, over every kind of reading.
24
+ def narrowings
25
+ Rails.application.eager_load!
26
+ descendants.filter_map(&:narrowing).uniq
27
+ end
28
+ end
29
+
30
+ # @return [String] Postgres role the statement runs as, which holds what it may read.
31
+ def runs_as = narrowing&.role || Omen.config.narrow_role
32
+
33
+ # @return [Hash] what is set before the statement, for a row policy of the host's to read.
34
+ def settings = narrowing ? { narrowing.setting => self[narrowing.by] } : {}
35
+ end
@@ -4,8 +4,10 @@ class Omen::Query
4
4
  UTC = { oid: 1114, name: 'timestamp', format: 0 }
5
5
 
6
6
  # @param sql [String] the statement Claude answered with.
7
- def initialize(sql)
7
+ # @param reading [Omen::Reading] the one it answers, which says what it may read.
8
+ def initialize(sql, reading)
8
9
  @sql = sql
10
+ @reading = reading
9
11
  end
10
12
 
11
13
  # A savepoint, so a statement Postgres rejects leaves the surrounding transaction usable.
@@ -23,10 +25,12 @@ private
23
25
  def record = Omen.config.record
24
26
 
25
27
  def run(connection)
26
- answered = Omen::Role.new(connection).around { executed connection }
28
+ answered = role(connection).around { executed connection }
27
29
  { result: answered.to_a.first(cap), provenance: Omen::Column.of(answered, connection) }
28
30
  end
29
31
 
32
+ def role(connection) = Omen::Role.new connection, @reading.runs_as, @reading.settings
33
+
30
34
  def executed(connection)
31
35
  raw = connection.raw_connection
32
36
  result = raw.exec_params capped, []
@@ -1,6 +1,6 @@
1
1
  # A thread of questions somebody asks Claude about the data this app holds.
2
2
  class Omen::Reading < Omen.config.record
3
- include Omen::Asked, Omen::Executed, Omen::Stated
3
+ include Omen::Asked, Omen::Executed, Omen::Narrowed, Omen::Stated
4
4
 
5
5
  has_many :questions, -> { order :id }, dependent: :destroy
6
6
 
@@ -15,6 +15,9 @@ class Omen::Reading < Omen.config.record
15
15
 
16
16
  # @return [String] the default representation (used in views).
17
17
  def to_s = questions.first&.text.to_s.truncate 80
18
+
19
+ # @return [String] the host's notes about its data, as this reading's asker is told them.
20
+ def notes = Omen.config.notes
18
21
  end
19
22
 
20
23
  ActiveSupport.run_load_hooks :omen_reading, Omen::Reading
@@ -0,0 +1,43 @@
1
+ # What a Postgres role may not read: the tables it holds no SELECT on, and the columns of the
2
+ # rest that a column-level grant left out. Each is asked of a role the database has to have, so
3
+ # a name it does not know refuses nothing here -- the statement is what says the app is
4
+ # misconfigured, in the one message that case is owed.
5
+ class Omen::Refusal
6
+ # Every column of the app the role is refused. Asked of the column rather than of the table,
7
+ # since a grant naming columns leaves the table itself ungranted and every one of these true.
8
+ COLUMNS = <<~SQL
9
+ SELECT c.relname, a.attname FROM pg_roles r, pg_class c
10
+ JOIN pg_namespace n ON n.oid = c.relnamespace
11
+ JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
12
+ WHERE r.rolname = ? AND n.nspname = 'public' AND c.relkind = 'r'
13
+ AND NOT has_column_privilege(r.oid, c.oid, a.attnum, 'SELECT')
14
+ SQL
15
+
16
+ # Every table of it none of whose columns the role may read, this gem's own included.
17
+ TABLES = <<~SQL
18
+ SELECT c.relname FROM pg_roles r, pg_class c
19
+ JOIN pg_namespace n ON n.oid = c.relnamespace
20
+ WHERE r.rolname = ? AND n.nspname = 'public' AND c.relkind = 'r'
21
+ AND NOT EXISTS (SELECT 1 FROM pg_attribute a
22
+ WHERE a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped
23
+ AND has_column_privilege(r.oid, c.oid, a.attnum, 'SELECT'))
24
+ SQL
25
+
26
+ # @param role [String] the Postgres role a statement will run as.
27
+ def initialize(role)
28
+ @role = role
29
+ end
30
+
31
+ # @return [Array<String>] the tables it may not read.
32
+ def tables = Omen.tables | asked(TABLES).flatten
33
+
34
+ # @return [Array<Array<String>>] the table and column of each one it may not read.
35
+ def columns = asked COLUMNS
36
+
37
+ private
38
+
39
+ def asked(sql)
40
+ named = Omen.config.record.sanitize_sql_array [ sql, @role ]
41
+ Omen.config.record.with_connection { |connection| connection.select_rows named }
42
+ end
43
+ end
@@ -0,0 +1,20 @@
1
+ # The one shape a reply may take, which is what makes every reply parse.
2
+ class Omen::Reply
3
+ # Both keys required, and no others admitted.
4
+ SHAPE = {
5
+ type: 'object', additionalProperties: false, required: %w[ sql note combine ],
6
+ properties: {
7
+ sql: { type: 'string',
8
+ description: 'The one PostgreSQL SELECT that answers the question, or ' \
9
+ 'empty to ask something first.', },
10
+ note: { type: 'string',
11
+ description: 'A sentence or two: what the query returns and any ' \
12
+ 'assumption made. If sql is empty, the question you ' \
13
+ 'need answered first.', },
14
+ combine: Omen::Combination::SCHEMA,
15
+ },
16
+ }
17
+
18
+ # @return [Hash] what a reply is constrained to, so that it always parses.
19
+ def self.output_config = { format_: { type: :json_schema, schema: SHAPE } }
20
+ end
@@ -8,8 +8,12 @@ class Omen::Role
8
8
  'the read-only role a statement runs as.'
9
9
 
10
10
  # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] the one to switch.
11
- def initialize(connection)
11
+ # @param name [String] the role to enter.
12
+ # @param settings [Hash] what to set first, which a row policy may read to scope the rows.
13
+ def initialize(connection, name, settings = {})
12
14
  @connection = connection
15
+ @name = name
16
+ @settings = settings
13
17
  end
14
18
 
15
19
  # Given up before anything of ours runs; a statement Postgres refused rolls it back instead.
@@ -21,9 +25,17 @@ class Omen::Role
21
25
 
22
26
  private
23
27
 
28
+ # Set before the role rather than after it, so a setting is written by the role that holds
29
+ # the rows rather than by the one that is about to be refused them.
24
30
  def enter
25
- @connection.execute "SET LOCAL ROLE #{@connection.quote_table_name Omen.config.narrow_role}"
31
+ @settings.each { |name, value| set name, value }
32
+ @connection.execute "SET LOCAL ROLE #{@connection.quote_table_name @name}"
26
33
  rescue ActiveRecord::StatementInvalid
27
34
  raise Unavailable, MISCONFIGURED
28
35
  end
36
+
37
+ # Through set_config, since a name carrying a dot is not one SET LOCAL can be given quoted.
38
+ def set(name, value)
39
+ @connection.raw_connection.exec_params 'SELECT set_config($1, $2, true)', [ name, value.to_s ]
40
+ end
29
41
  end
@@ -1,22 +1,43 @@
1
- # What Claude writes its SQL against: the app's own schema, less the tables a reading is kept in.
1
+ # What Claude writes its SQL against: the app's own schema, less what the role may not read.
2
2
  class Omen::Schema
3
3
  # A plain index is a note about speed; a unique one is a fact about the rows, so it stays.
4
4
  HINT = /^\s*t\.index (?!.*unique: true).*\n/
5
5
 
6
- # @return [String] the schema, without this feature's own tables, their keys or their enum.
7
- def text = without_orphan_enums hidden.inject(source) { |left, name| without_table left, name }
6
+ # @param role [String] the Postgres role the statement will run as.
7
+ def initialize(role)
8
+ @role = role
9
+ end
10
+
11
+ # @return [String] the schema, without a table or a column the role is refused, and without
12
+ # an enum or a foreign key left behind by one.
13
+ def text = without_orphan_enums without_columns without_tables source
8
14
 
9
15
  private
10
16
 
11
17
  def source = File.read(Omen.config.schema).gsub HINT, ''
12
18
 
13
- def hidden = Omen.tables
19
+ def refusal = Omen::Refusal.new @role
20
+
21
+ def without_tables(text)
22
+ refusal.tables.inject(text) { |left, name| without_table left, name }
23
+ end
24
+
25
+ def without_columns(text)
26
+ refusal.columns.inject(text) { |left, (table, column)| without_column left, table, column }
27
+ end
14
28
 
15
29
  def without_table(text, name)
16
30
  text.gsub(/^ create_table "#{name}".*?\n end\n\n?/m, '')
17
31
  .gsub(/^ add_foreign_key ("#{name}"|"\w+", "#{name}").*\n/, '')
18
32
  end
19
33
 
34
+ def without_column(text, table, column)
35
+ text.sub(/^ create_table "#{table}".*?\n end\n/m) do |block|
36
+ block.gsub(/^ t\.\w+ "#{column}".*\n/, '')
37
+ .gsub(/^ t\.index \[[^\]]*"#{column}".*\n/, '')
38
+ end
39
+ end
40
+
20
41
  def without_orphan_enums(text)
21
42
  # Derived rather than named, so a type the cut tables shared with another survives
22
43
  text.gsub(/^ create_enum "(\w+)".*\n/) do |line|
@@ -0,0 +1,6 @@
1
+ class AddTypeToOmenReadings < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :omen_readings, :type, :string
4
+ add_index :omen_readings, :type
5
+ end
6
+ end
@@ -2,7 +2,7 @@ require 'rails/generators/active_record'
2
2
 
3
3
  module Omen
4
4
  module Generators
5
- # Everything installing this gem writes into a host: three migrations and one initializer.
5
+ # Everything installing this gem writes into a host: its migrations and one initializer.
6
6
  # Not the roles or the database function, which are a rake task, because a copy of those
7
7
  # would drift from what the gem goes on to expect and nothing would detect it.
8
8
  class InstallGenerator < Rails::Generators::Base
@@ -23,6 +23,10 @@ Omen.configure do |config|
23
23
  # there are more without counting them.
24
24
  # config.maximum_rows = 100
25
25
 
26
+ # How many turns of a thread travel with the next question. A thread nobody clears would
27
+ # otherwise carry its whole history into each one, and be charged for it again each time.
28
+ # config.remembered = 20
29
+
26
30
  # Left unset, the Anthropic SDK resolves ANTHROPIC_API_KEY and its own wider chain.
27
31
  # config.api_key = Rails.application.credentials.dig :anthropic, :api_key
28
32
 
@@ -0,0 +1,25 @@
1
+ Description:
2
+ Writes the pages a reading is seen through, into this app: a name for one, a controller,
3
+ three views and a route. All of it is this app's from the moment it lands -- rename it,
4
+ restyle it, throw it away.
5
+
6
+ The controller descends from ApplicationController, so whatever guards the rest of this
7
+ app guards the one page that will run SQL across all of it.
8
+
9
+ Where Turbo is installed, the show page subscribes to the reading and an initializer
10
+ declaring broadcasts_refreshes is written beside it, so an answer arrives on its own
11
+ rather than waiting for a reload.
12
+
13
+ Example:
14
+ bin/rails generate omen:pages
15
+
16
+ creates app/models/inquiry.rb
17
+ app/controllers/inquiries_controller.rb
18
+ app/views/inquiries/index.html.erb
19
+ app/views/inquiries/show.html.erb
20
+ app/views/inquiries/_form.html.erb
21
+ route resources :inquiries
22
+
23
+ bin/rails generate omen:pages Consultation
24
+
25
+ the same, under a name of this app's own choosing.
@@ -0,0 +1,70 @@
1
+ require 'rails/generators/named_base'
2
+
3
+ module Omen
4
+ module Generators
5
+ # The least a host needs to see a reading at all: a name for one, a controller, three views
6
+ # and a route. All of it lands in the host and is the host's from then on -- Omen has no
7
+ # opinion about what a reading looks like. It has one about what it takes to draw one, and
8
+ # that is the part nobody should have to learn before their first question.
9
+ class PagesGenerator < Rails::Generators::NamedBase
10
+ source_root File.expand_path('templates', __dir__)
11
+
12
+ # One per action that renders, and the form the other two share.
13
+ VIEWS = %w[ index show _form ]
14
+
15
+ # Its own file rather than a line in the initializer, so that deleting these pages deletes
16
+ # everything they asked for. Declared on Omen::Reading and not on the name below it,
17
+ # because a question reaches its reading through the association: what a job is handed,
18
+ # and so what it broadcasts as, is always the gem's own class.
19
+ BROADCASTS = <<~RUBY
20
+ # Refreshes every page showing a reading when its answer lands, which is long after the
21
+ # response that asked for it went out. Written by `rails g omen:pages`, and safe to
22
+ # delete along with the pages it was written for.
23
+ ActiveSupport.on_load(:omen_reading) { broadcasts_refreshes }
24
+ RUBY
25
+
26
+ remove_argument :name
27
+ argument :name, type: :string, default: 'Inquiry',
28
+ desc: 'What this app calls a reading of its own'
29
+
30
+ # Raised on rather than written over: a host that already calls something Inquiry would
31
+ # otherwise find these pages standing where its own class used to, and quietly. The name
32
+ # is the argument, so `omen:pages Consultation` is the whole of the fix.
33
+ # @return [void]
34
+ def check_collisions = class_collisions class_name, "#{plural_name.camelize}Controller"
35
+
36
+ # @return [void]
37
+ def copy_model = template 'model.rb', "app/models/#{singular_name}.rb"
38
+
39
+ # @return [void]
40
+ def copy_controller
41
+ template 'controller.rb', "app/controllers/#{plural_name}_controller.rb"
42
+ end
43
+
44
+ # @return [void]
45
+ def copy_views
46
+ VIEWS.each do |view|
47
+ template "#{view}.html.erb", "app/views/#{plural_name}/#{view}.html.erb"
48
+ end
49
+ end
50
+
51
+ # @return [void]
52
+ def add_route = route "resources :#{plural_name}"
53
+
54
+ # @return [void]
55
+ def declare_broadcasts
56
+ return unless turbo?
57
+
58
+ create_file 'config/initializers/omen_broadcasts.rb', BROADCASTS
59
+ end
60
+
61
+ private
62
+
63
+ # Asked once, here, rather than by the pages at every render: a host either has it or has
64
+ # no use for the line. Without one the pages still work, they only stop arriving on their
65
+ # own, and a reload is what shows an answer.
66
+ # @return [Boolean] whether this app has a Turbo to broadcast a refresh over.
67
+ def turbo? = Object.const_defined? :Turbo
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,8 @@
1
+ <%%= form_with model: <%= singular_name %> do |form| %>
2
+ <%% if <%= singular_name %>.errors.any? %>
3
+ <p style="color: red"><%%= <%= singular_name %>.errors.full_messages.to_sentence %></p>
4
+ <%% end %>
5
+
6
+ <div><%%= form.textarea :question, rows: 3, cols: 60 %></div>
7
+ <div><%%= form.submit 'Ask' %></div>
8
+ <%% end %>
@@ -0,0 +1,54 @@
1
+ # The pages of a reading. Written by `rails g omen:pages` and this app's from then on, which is
2
+ # also why it descends from ApplicationController: whatever guards the rest of this app guards
3
+ # the one page here that will run SQL across all of it.
4
+ class <%= plural_name.camelize %>Controller < ApplicationController
5
+ before_action :set_<%= singular_name %>, only: %i[ show update destroy ]
6
+
7
+ # GET /<%= plural_name %>
8
+ def index
9
+ @<%= plural_name %> = <%= class_name %>.order id: :desc
10
+ @<%= singular_name %> = <%= class_name %>.new
11
+ end
12
+
13
+ # GET /<%= plural_name %>/1
14
+ def show
15
+ end
16
+
17
+ # POST /<%= plural_name %>
18
+ # Opening a reading is asking its first question, so there is no page for a blank one.
19
+ def create
20
+ @<%= singular_name %> = <%= class_name %>.new <%= singular_name %>_params
21
+
22
+ if @<%= singular_name %>.save
23
+ redirect_to @<%= singular_name %>
24
+ else
25
+ @<%= plural_name %> = <%= class_name %>.order id: :desc
26
+ render :index, status: :unprocessable_content
27
+ end
28
+ end
29
+
30
+ # PATCH/PUT /<%= plural_name %>/1
31
+ # A reading is never edited: what was asked is what it stays. Asking again adds to the thread,
32
+ # and Claude is shown the whole of it when it answers.
33
+ def update
34
+ question = <%= singular_name %>_params[:question]
35
+ @<%= singular_name %>.ask(question) if question.present?
36
+
37
+ redirect_to @<%= singular_name %>
38
+ end
39
+
40
+ # DELETE /<%= plural_name %>/1
41
+ def destroy
42
+ @<%= singular_name %>.destroy!
43
+
44
+ redirect_to <%= plural_name %>_path, status: :see_other
45
+ end
46
+
47
+ private
48
+
49
+ def set_<%= singular_name %>
50
+ @<%= singular_name %> = <%= class_name %>.find params.expect(:id)
51
+ end
52
+
53
+ def <%= singular_name %>_params = params.expect <%= singular_name %>: [ :question ]
54
+ end
@@ -0,0 +1,12 @@
1
+ <h1><%= plural_name.humanize %></h1>
2
+
3
+ <%%= render 'form', <%= singular_name %>: @<%= singular_name %> %>
4
+
5
+ <ul>
6
+ <%% @<%= plural_name %>.each do |<%= singular_name %>| %>
7
+ <li>
8
+ <%%= link_to <%= singular_name %>, <%= singular_name %> %>
9
+ <%% if <%= singular_name %>.answering? %><em>answering</em><%% end %>
10
+ </li>
11
+ <%% end %>
12
+ </ul>
@@ -0,0 +1,7 @@
1
+ # A reading of this app's own data: what somebody asked Claude, and what came back.
2
+ #
3
+ # Omen::Reading has a type column nowhere, so this is a second name for the same rows rather
4
+ # than a kind of them. <%= class_name %>.all carries no condition, and a belongs_to declared here
5
+ # is one that every reading has.
6
+ class <%= class_name %> < Omen::Reading
7
+ end
@@ -0,0 +1,45 @@
1
+ <%- if turbo? -%>
2
+ <%%= turbo_stream_from @<%= singular_name %>.becomes(Omen::Reading) %>
3
+
4
+ <%- end -%>
5
+ <p><%%= link_to 'All <%= plural_name %>', <%= plural_name %>_path %></p>
6
+
7
+ <%% @<%= singular_name %>.questions.each do |question| %>
8
+ <h2><%%= question.text %></h2>
9
+
10
+ <%% if (answer = question.answer) %>
11
+ <p><%%= answer.note %></p>
12
+
13
+ <%% if answer.error.present? %>
14
+ <p style="color: red"><%%= answer.error %></p>
15
+ <%% elsif answer.sql.present? %>
16
+ <details>
17
+ <summary>SQL</summary>
18
+ <pre><%%= answer.sql %></pre>
19
+ </details>
20
+
21
+ <table border="1" cellpadding="4">
22
+ <tr><%% answer.shown.first&.each_key do |header| %><th><%%= header %></th><%% end %></tr>
23
+ <%% answer.shown.each do |row| %>
24
+ <tr><%% row.each_value do |value| %><td><%%= value %></td><%% end %></tr>
25
+ <%% end %>
26
+ </table>
27
+
28
+ <%% if answer.truncated? %>
29
+ <p>The first <%%= Omen.config.maximum_rows %> rows, and there are more.</p>
30
+ <%% end %>
31
+ <%% end %>
32
+ <%% end %>
33
+ <%% end %>
34
+
35
+ <%% if @<%= singular_name %>.answering? %>
36
+ <p>Claude is working on it.</p>
37
+ <%% else %>
38
+ <%% if @<%= singular_name %>.failed? %>
39
+ <p style="color: red">That one could not be answered. Asking again starts it over.</p>
40
+ <%% end %>
41
+
42
+ <%%= render 'form', <%= singular_name %>: @<%= singular_name %> %>
43
+ <%% end %>
44
+
45
+ <%%= button_to 'Delete', @<%= singular_name %>, method: :delete %>
data/lib/omen/bound.rb ADDED
@@ -0,0 +1,26 @@
1
+ module Omen
2
+ # Which roles a restrictive policy really holds back. A role that inherits the privileges of
3
+ # a narrowed one is narrowed along with it, so a page can go empty for somebody nobody meant
4
+ # to narrow -- silently, since an empty answer raises nothing.
5
+ module Bound
6
+ # A policy is written for one role and named after it, so a binding anybody asked for is
7
+ # one whose policy name ends in the name of the role it turned up for.
8
+ ROLES = <<~SQL
9
+ SELECT p.tablename, p.policyname, r.rolname FROM pg_policies p
10
+ CROSS JOIN LATERAL unnest(p.roles) AS named(rolname)
11
+ JOIN pg_roles r ON pg_has_role(r.oid, named.rolname::regrole::oid, 'USAGE')
12
+ WHERE p.permissive = 'RESTRICTIVE' AND p.schemaname = 'public' AND NOT r.rolsuper
13
+ ORDER BY p.tablename, p.policyname, r.rolname
14
+ SQL
15
+
16
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] any one.
17
+ # @return [Array<Array<String>>] the table, the policy and the role it holds back.
18
+ def self.roles(connection) = connection.select_rows ROLES
19
+
20
+ # @param connection [ActiveRecord::ConnectionAdapters::AbstractAdapter] any one.
21
+ # @return [Array<Array<String>>] the ones a host did not ask for.
22
+ def self.surprises(connection)
23
+ roles(connection).reject { |_table, policy, role| policy.end_with? role }
24
+ end
25
+ end
26
+ end
data/lib/omen/config.rb CHANGED
@@ -15,6 +15,9 @@ module Omen
15
15
  # How many rows of one answer a page will show.
16
16
  attr_accessor :maximum_rows
17
17
 
18
+ # How many turns of a thread travel with the next question.
19
+ attr_accessor :remembered
20
+
18
21
  # The key the Anthropic API is reached with. Left unset, the SDK resolves one of its own.
19
22
  attr_accessor :api_key
20
23
 
@@ -26,6 +29,7 @@ module Omen
26
29
  @narrow_role = 'omen_inquirer'
27
30
  @claude_model = 'claude-opus-5'
28
31
  @maximum_rows = 100
32
+ @remembered = 20
29
33
  @schema_path = 'db/schema.rb'
30
34
  end
31
35