samagotchi 0.115.0 → 0.116.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/bin/chi +2 -0
  4. data/docs/cli.md +5 -1
  5. data/docs/configuration.md +7 -0
  6. data/docs/guardrails.md +188 -1
  7. data/docs/sessions.md +1 -1
  8. data/lib/samagotchi/bridge/turn_accumulator.rb +4 -0
  9. data/lib/samagotchi/bridge.rb +26 -0
  10. data/lib/samagotchi/bridge_client.rb +7 -0
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/config.rb +15 -0
  13. data/lib/samagotchi/engine.rb +6 -1
  14. data/lib/samagotchi/guardrail_wiring.rb +166 -2
  15. data/lib/samagotchi/guardrails/approval.rb +20 -0
  16. data/lib/samagotchi/guardrails/targets.rb +23 -2
  17. data/lib/samagotchi/guardrails/verdict.rb +23 -3
  18. data/lib/samagotchi/guardrails_command.rb +329 -0
  19. data/lib/samagotchi/memory_bundle/index_updater.rb +13 -0
  20. data/lib/samagotchi/session_access/contract.rb +1 -1
  21. data/lib/samagotchi/session_commands.rb +79 -2
  22. data/lib/samagotchi/session_metrics.rb +7 -1
  23. data/lib/samagotchi/system_one/answer.rb +78 -0
  24. data/lib/samagotchi/system_one/approval_question.rb +113 -0
  25. data/lib/samagotchi/system_one/client.rb +89 -0
  26. data/lib/samagotchi/system_one/errors.rb +14 -0
  27. data/lib/samagotchi/system_one/fake_backend.rb +45 -0
  28. data/lib/samagotchi/system_one/http_backend.rb +129 -0
  29. data/lib/samagotchi/system_one/log.rb +133 -0
  30. data/lib/samagotchi/system_one/question.rb +86 -0
  31. data/lib/samagotchi/system_one/record.rb +83 -0
  32. data/lib/samagotchi/system_one/self_protect.rb +90 -0
  33. data/lib/samagotchi/system_one/similar.rb +69 -0
  34. data/lib/samagotchi/system_one.rb +25 -0
  35. data/lib/samagotchi/terminal_ui/attached_loop.rb +15 -4
  36. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -0
  37. data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
  38. data/lib/samagotchi/version.rb +1 -1
  39. data/lib/samagotchi/web/app.rb +1 -0
  40. data/lib/samagotchi/web/public/activity.js +10 -0
  41. data/lib/samagotchi/web/public/app.js +3 -0
  42. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  43. data/lib/samagotchi/web/public/composer_fold.js +8 -8
  44. data/lib/samagotchi/web/public/composer_grip.js +65 -22
  45. data/lib/samagotchi/web/public/composer_size.js +30 -2
  46. data/lib/samagotchi/web/public/data.js +10 -0
  47. data/lib/samagotchi/web/public/favicon.svg +1 -1
  48. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  49. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  50. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  51. data/lib/samagotchi/web/public/index.html +5 -0
  52. data/lib/samagotchi/web/public/system_one_wrong.js +32 -0
  53. data/lib/samagotchi/web/public/timing.js +9 -5
  54. data/lib/samagotchi/web/public/turn_events.js +1 -0
  55. data/lib/samagotchi/web/public/turn_html.js +13 -2
  56. data/lib/samagotchi/web/session_control.rb +22 -0
  57. data/lib/samagotchi/web/session_show.rb +35 -5
  58. metadata +15 -1
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+ require "time"
7
+
8
+ require_relative "../log"
9
+
10
+ module Samagotchi
11
+ module SystemOne
12
+ # The judgments log: one JSON line per System 1 judgment, and a later
13
+ # line for the user's answer to the ask it judged (docs/guardrails.md,
14
+ # "System 1"). It sits beside the approvals store,
15
+ # `$XDG_STATE_HOME/samagotchi/guardrails/judgments.jsonl`, and is written
16
+ # append-only under an exclusive flock: several workers judge at once,
17
+ # and a line is never rewritten to add its label. Past 5 MB the file is
18
+ # rewritten, under the same lock, keeping the newest half of its lines.
19
+ #
20
+ # Nothing here can break an approval: every write that fails is one
21
+ # Log.warn and a nil, and the ask reaches the user as it would have.
22
+ class Log
23
+ FILE = "judgments.jsonl"
24
+ # Above this the file is rewritten keeping the newest half of the lines.
25
+ MAX_BYTES = 5 * 1024 * 1024
26
+ MODE = 0o600
27
+ # What a label's answer can be (P2a writes the first four;
28
+ # "should_have_asked" comes with `/guardrails wrong`).
29
+ ANSWERS = %w[allow_once allow_wide deny none should_have_asked].freeze
30
+
31
+ attr_reader :dir, :path
32
+
33
+ # @param dir [String] the guardrails store dir (Guardrails::Approvals.dir_for)
34
+ # @param warn [#call] (reason) — the record for a failed write
35
+ def initialize(dir:, warn: nil)
36
+ @dir = dir
37
+ @path = File.join(dir, FILE)
38
+ @warn = warn || ->(reason) { Samagotchi::Log.warn(:guardrails, "system_one_log_failed", echo: "[samagotchi:guardrails] #{reason}") }
39
+ end
40
+
41
+ # @param fields [Hash] the judgment's own fields (rule, tool, p, …)
42
+ # @return [String, nil] the judgment's id, nil when nothing was written
43
+ def judgment(fields)
44
+ id = SecureRandom.uuid
45
+ append({ "kind" => "judgment", "id" => id, "at" => Time.now.utc.iso8601 }.merge(fields)) ? id : nil
46
+ end
47
+
48
+ # @param id [String] a judgment's id
49
+ # @param answer [String] one of ANSWERS
50
+ # @return [Boolean] whether the line was written
51
+ def label(id, answer)
52
+ return false if id.to_s.strip.empty?
53
+
54
+ append({ "kind" => "label", "id" => id.to_s, "at" => Time.now.utc.iso8601, "answer" => answer.to_s })
55
+ end
56
+
57
+ # The judgments, oldest first, each joined with its LAST label
58
+ # (answer: nil when it has none). Lines that don't parse are skipped;
59
+ # labels of a judgment that isn't there are dropped.
60
+ # @return [Array<Hash>]
61
+ def entries
62
+ judgments = {}
63
+ labels = {}
64
+ with_lock(File::LOCK_SH) do |file|
65
+ file.each_line do |line|
66
+ data = parse(line)
67
+ next if data.nil?
68
+
69
+ case data["kind"]
70
+ when "judgment" then judgments[data["id"]] = data
71
+ when "label" then labels[data["id"]] = data["answer"] if data.key?("id")
72
+ end
73
+ end
74
+ end
75
+ judgments.map { |id, judgment| judgment.merge("answer" => labels[id]) }
76
+ rescue StandardError => e
77
+ warn_about(e)
78
+ []
79
+ end
80
+
81
+ private
82
+
83
+ def append(line)
84
+ with_lock(File::LOCK_EX) do |file|
85
+ # The handle is read-write (not O_APPEND) so #trim can rewrite the
86
+ # file: write at the end, under the lock nobody else holds.
87
+ file.seek(file.size)
88
+ file.puts(JSON.generate(line))
89
+ trim(file)
90
+ end
91
+ true
92
+ rescue StandardError => e
93
+ warn_about(e)
94
+ false
95
+ end
96
+
97
+ # @param mode [Integer] File::LOCK_EX for a write, LOCK_SH to read
98
+ def with_lock(mode)
99
+ FileUtils.mkdir_p(@dir)
100
+ File.open(@path, File::RDWR | File::CREAT, MODE) do |file|
101
+ file.flock(mode)
102
+ yield file
103
+ end
104
+ end
105
+
106
+ # Past MAX_BYTES, keep the newest half of the lines (whole, so a
107
+ # judgment and its labels stay together as far as the cut reaches).
108
+ def trim(file)
109
+ file.flush
110
+ return if file.size <= MAX_BYTES
111
+
112
+ file.rewind
113
+ lines = file.readlines
114
+ keep = lines.last([lines.size / 2, 1].max).join
115
+ file.rewind
116
+ file.write(keep)
117
+ file.flush
118
+ file.truncate(keep.bytesize)
119
+ end
120
+
121
+ def parse(line)
122
+ data = JSON.parse(line)
123
+ data.is_a?(Hash) ? data : nil
124
+ rescue JSON::ParserError
125
+ nil
126
+ end
127
+
128
+ def warn_about(error)
129
+ @warn.call("system one log: #{path}: #{error.class}: #{error.message}")
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module SystemOne
5
+ # The question types the judgment contract has.
6
+ QUESTION_TYPES = %i[noul choice score].freeze
7
+
8
+ # The questions of one ask, as an Array of Question: a Hash of
9
+ # id => Question (or of wire fields), or an Array of Questions.
10
+ def self.normalize_questions(questions)
11
+ case questions
12
+ when Hash then questions.map { |id, value| Question.coerce(id, value) }
13
+ when Array then questions.map { |value| value.is_a?(Question) ? value : Question.coerce(value.to_s, value) }
14
+ else raise ArgumentError, "questions must be a hash of id => question, got #{questions.class}"
15
+ end
16
+ end
17
+
18
+ # One question of a System One request: id, type (:noul a yes/no
19
+ # judgment, :choice one of the named criteria, :score one of the listed
20
+ # levels), the instruction the model reads, and the criteria a :choice
21
+ # (name => description or nil) or :score (a list of level names) needs.
22
+ #
23
+ # Question.new(id: "git-push", type: "noul", instructions: "Would the user allow this git push?")
24
+ Question = Data.define(:id, :type, :instructions, :criteria) do
25
+ def initialize(id:, type:, instructions:, criteria: nil)
26
+ id = id.to_s.strip
27
+ raise ArgumentError, "SystemOne::Question needs an id" if id.empty?
28
+
29
+ type = type.to_s.strip.downcase.to_sym
30
+ unless QUESTION_TYPES.include?(type)
31
+ raise ArgumentError, "question #{id}: type must be one of #{QUESTION_TYPES.join(", ")}, got #{type.inspect}"
32
+ end
33
+
34
+ instructions = instructions.to_s
35
+ raise ArgumentError, "question #{id}: instructions are required" if instructions.strip.empty?
36
+
37
+ super(id: id, type: type, instructions: instructions, criteria: criteria_for(id, type, criteria))
38
+ end
39
+
40
+ # The entry of the request's questions map.
41
+ def to_wire
42
+ wire = { "type" => type.to_s, "instructions" => instructions }
43
+ wire["criteria"] = criteria unless criteria.nil?
44
+ wire
45
+ end
46
+
47
+ # A Question as given, or a {type:, instructions:, criteria:} hash
48
+ # (string or symbol keys) — what a caller has on hand.
49
+ def self.coerce(id, value)
50
+ return value if value.is_a?(Question)
51
+
52
+ unless value.is_a?(Hash)
53
+ raise ArgumentError, "question #{id}: expected a SystemOne::Question or a hash, got #{value.class}"
54
+ end
55
+
56
+ field = ->(name) { value.key?(name) ? value[name] : value[name.to_sym] }
57
+ new(id: id, type: field.call("type"), instructions: field.call("instructions"), criteria: field.call("criteria"))
58
+ end
59
+
60
+ private
61
+
62
+ def criteria_for(id, type, criteria)
63
+ case type
64
+ when :noul
65
+ raise ArgumentError, "question #{id}: an noul question takes no criteria" unless criteria.nil?
66
+
67
+ nil
68
+ when :choice
69
+ unless criteria.is_a?(Hash) && !criteria.empty? &&
70
+ criteria.all? { |name, description| !name.to_s.strip.empty? && (description.nil? || description.is_a?(String)) }
71
+ raise ArgumentError, "question #{id}: a choice question takes criteria as {name => description or nil}"
72
+ end
73
+
74
+ criteria.to_h { |name, description| [name.to_s, description] }
75
+ else
76
+ unless criteria.is_a?(Array) && !criteria.empty? &&
77
+ criteria.all? { |level| level.is_a?(String) && !level.strip.empty? }
78
+ raise ArgumentError, "question #{id}: a score question takes criteria as a list of level names"
79
+ end
80
+
81
+ criteria.map(&:to_s)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module SystemOne
5
+ # One rule's record: what System 1 has judged for it and what the user
6
+ # did about those asks. Built from `SystemOne::Log#entries` (judgments
7
+ # joined with their last label), filtered to one rule. The gate before
8
+ # System 1 answers for the user: enough clean allows at the threshold,
9
+ # and no false allow among the rule's last 20 labelled asks
10
+ # (docs/guardrails.md, "System 1").
11
+ #
12
+ # An ask with no label, no probability, or a `none` label counts for
13
+ # neither side: only a judgment the user actually answered teaches
14
+ # anything.
15
+ class Record
16
+ # The labels that mean the user let the call through.
17
+ ALLOW_LABELS = %w[allow_once allow_wide].freeze
18
+ # A judgment at the threshold that the user did not allow: System 1
19
+ # was wrong, and the rule's record is not clean.
20
+ FALSE_ALLOW_LABELS = %w[deny should_have_asked].freeze
21
+ # How many of the rule's labelled asks the false-allow check looks at.
22
+ WINDOW = 20
23
+
24
+ attr_reader :rule, :entries
25
+
26
+ # @param entries [Array<Hash>] SystemOne::Log#entries, oldest first
27
+ # @param rule [String] the rule the record is for
28
+ def initialize(entries, rule:)
29
+ @rule = rule.to_s
30
+ @entries = Array(entries).select { |entry| entry["rule"].to_s == @rule }
31
+ end
32
+
33
+ # @param threshold [Float] the probability an auto-allow needs
34
+ # @param min_record [Integer] how many clean allows the rule needs
35
+ # @return [Boolean] whether System 1 may answer this rule for the user
36
+ def qualifies?(threshold:, min_record:)
37
+ clean = high(threshold).count { |entry| ALLOW_LABELS.include?(entry["answer"].to_s) }
38
+ return false if clean < min_record.to_i
39
+
40
+ !false_allow?(labeled.last(WINDOW), threshold)
41
+ end
42
+
43
+ # The counts a `/guardrails` listing shows: how many asks the rule has,
44
+ # how many were answered, and how each was answered.
45
+ # @return [Hash]
46
+ def summary
47
+ labeled_entries = labeled
48
+ {
49
+ rule: rule,
50
+ asks: entries.size,
51
+ labelled: labeled_entries.size,
52
+ allow_once: count("allow_once"),
53
+ allow_wide: count("allow_wide"),
54
+ deny: count("deny"),
55
+ should_have_asked: count("should_have_asked"),
56
+ none: count("none"),
57
+ no_label: entries.size - labeled_entries.size,
58
+ no_p: entries.count { |entry| entry["p"].nil? }
59
+ }
60
+ end
61
+
62
+ private
63
+
64
+ # The asks of the rule the user answered.
65
+ def labeled
66
+ entries.reject { |entry| entry["answer"].nil? }
67
+ end
68
+
69
+ # The asks judged at or above +threshold+.
70
+ def high(threshold)
71
+ entries.select { |entry| entry["p"] && entry["p"].to_f >= threshold.to_f }
72
+ end
73
+
74
+ def false_allow?(list, threshold)
75
+ list.any? { |entry| entry["p"] && entry["p"].to_f >= threshold.to_f && FALSE_ALLOW_LABELS.include?(entry["answer"].to_s) }
76
+ end
77
+
78
+ def count(label)
79
+ entries.count { |entry| entry["answer"].to_s == label }
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../paths"
4
+ require_relative "../config"
5
+ require_relative "../memory_paths"
6
+ require_relative "../hooks/loader"
7
+ require_relative "../guardrails/protected_paths"
8
+
9
+ module Samagotchi
10
+ module SystemOne
11
+ # The calls System 1 may never answer for, whatever the rule's record
12
+ # says: one that changes chi itself. An auto-allow on config.yml, the
13
+ # hooks, the installed bundles or the guardrails store would let the
14
+ # agent widen its own guardrails without the user (docs/guardrails.md,
15
+ # "System 1"). The memories dir is deliberately not one of them:
16
+ # reading and appending a memory file stays eligible.
17
+ #
18
+ # Pure: it only reads the paths as this process's environment names them,
19
+ # and answers on the approval's own facts (Guardrails::Approval.payload's
20
+ # approval:), the same shape ParentApprovals.protected_facts? takes.
21
+ module SelfProtect
22
+ # The rules that let one agent answer chi's questions: an auto-allow
23
+ # there is one agent approving another's, so the user decides them even
24
+ # when they are listed in `guardrails.system_one.rules`.
25
+ NEVER_AUTO = %w[chi-answer-piped chi-answer-http chi-broadcast].freeze
26
+ # chi's config file, however the user names it in config.yml.
27
+ CONFIG_FILE = /\Aconfig[\w-]*\.ya?ml\z/i
28
+ # The dirs as a shell command may spell them when the settings point
29
+ # elsewhere: chi's default config dir and default state dir. A path
30
+ # into the memories dir is not a control file.
31
+ DEFAULT_CONFIG_TEXT = %r{\B~/\.config/samagotchi(?!/memories)}
32
+ DEFAULT_STATE_TEXT = %r{\B~/\.local/state/samagotchi}
33
+
34
+ module_function
35
+
36
+ # @param approval [Hash, nil] the ask's approval facts (rule, tool,
37
+ # paths, command)
38
+ # @return [Boolean] whether System 1 must not answer this ask
39
+ def refuse?(approval)
40
+ return false unless approval.is_a?(Hash)
41
+ return true if NEVER_AUTO.include?(fetch(approval, :rule).to_s)
42
+
43
+ dirs = control_dirs
44
+ paths = Array(fetch(approval, :paths)).map(&:to_s).reject(&:empty?)
45
+ return true if paths.any? { |path| control_path?(path, dirs) }
46
+
47
+ command = fetch(approval, :command).to_s
48
+ !command.empty? && control_text?(command, dirs)
49
+ end
50
+
51
+ # The dirs whose files are chi's own: its hooks, its installed bundles,
52
+ # the guardrails store (approvals.json and judgments.jsonl).
53
+ # @return [Array<String>]
54
+ def control_dirs
55
+ [Hooks::Loader.hooks_dir, MemoryPaths.bundles_dir, File.join(Paths.state_dir, "guardrails")]
56
+ .compact.map { |dir| dir.to_s.chomp("/") }.reject(&:empty?)
57
+ end
58
+
59
+ def control_path?(path, dirs)
60
+ real = Guardrails::ProtectedPaths.real(path)
61
+ return true if dirs.any? { |dir| Guardrails::ProtectedPaths.within?(real, dir) }
62
+ return true if config_file?(real)
63
+
64
+ control_text?(path, dirs)
65
+ end
66
+
67
+ # config.yml in chi's config dir — not the memories beside it.
68
+ def config_file?(path)
69
+ Guardrails::ProtectedPaths.within?(path, ConfigFile.config_dir) && File.basename(path).match?(CONFIG_FILE)
70
+ end
71
+
72
+ # A command or path naming one of those dirs, in the spelling this
73
+ # process uses or the default `~/.config/samagotchi` one.
74
+ def control_text?(text, dirs)
75
+ dirs.any? { |dir| text.include?(dir) || text.include?(File.expand_path(dir)) } ||
76
+ text.match?(config_text) || text.match?(DEFAULT_CONFIG_TEXT) || text.match?(DEFAULT_STATE_TEXT)
77
+ end
78
+
79
+ # chi's config dir as text — everything under it except the memories,
80
+ # which the model works with.
81
+ def config_text
82
+ Regexp.new("#{Regexp.escape(ConfigFile.config_dir)}(?![/]memories)")
83
+ end
84
+
85
+ def fetch(hash, key)
86
+ hash.key?(key) ? hash[key] : hash[key.to_s]
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ module SystemOne
5
+ # The few-shot examples an ask carries beside its own facts: the earlier
6
+ # asks the user actually answered, the most alike first — this rule in
7
+ # this repository, then this rule, then whatever shares the most words
8
+ # with this call. No embeddings: a word overlap, with the newer ask first
9
+ # when two tie, so the same log always gives the same five
10
+ # (docs/guardrails.md, "System 1").
11
+ module Similar
12
+ # How many examples the state can carry.
13
+ MAX = 5
14
+ # A "token" of a call, for the overlap: a word, lower-cased.
15
+ TOKEN = /[a-z0-9_]+/
16
+
17
+ module_function
18
+
19
+ # @param entries [Array<Hash>] SystemOne::Log#entries, oldest first —
20
+ # the same read the record gate uses
21
+ # @param rule [String, nil] the rule asking now
22
+ # @param repo_name [String, nil] the repository this ask is in
23
+ # @param call_text [String] the call to approve (ApprovalQuestion.call_text)
24
+ # @return [Array<Hash>] at most MAX examples, most alike first; [] when
25
+ # the log has nothing labelled to offer
26
+ def select(entries:, rule:, repo_name:, call_text:)
27
+ words = tokens(call_text)
28
+ ranked = Array(entries).each_with_index.select { |entry, _index| labelled?(entry) }
29
+ ranked = ranked.sort_by { |entry, index| [tier(entry, rule, repo_name), -overlap(entry["call"], words), -index] }
30
+ ranked.first(MAX).map { |entry, _index| example(entry) }
31
+ end
32
+
33
+ # A judgment teaches something only when the user answered it: no label
34
+ # or a `none` says nothing, and a line with no rule cannot be matched.
35
+ def labelled?(entry)
36
+ answer = entry["answer"].to_s
37
+ !answer.empty? && answer != "none" && !entry["rule"].to_s.strip.empty?
38
+ end
39
+
40
+ # 0: this rule in this repository. 1: this rule anywhere. 2: any other
41
+ # ask the user answered.
42
+ def tier(entry, rule, repo_name)
43
+ same_rule = entry["rule"].to_s == rule.to_s
44
+ return 0 if same_rule && !repo_name.to_s.strip.empty? && entry["repo_name"].to_s == repo_name.to_s
45
+ return 1 if same_rule
46
+
47
+ 2
48
+ end
49
+
50
+ # How many of this call's words the logged call shares.
51
+ def overlap(call, words)
52
+ return 0 if words.empty?
53
+
54
+ (tokens(call) & words).size
55
+ end
56
+
57
+ def tokens(text)
58
+ text.to_s.downcase.scan(TOKEN).uniq
59
+ end
60
+
61
+ # What the judge is shown about an ask: how it was judged and what the
62
+ # user did, as the log holds it.
63
+ def example(entry)
64
+ { "rule" => entry["rule"].to_s, "repo" => entry["repo_name"], "call" => entry["call"],
65
+ "answer" => entry["answer"].to_s }.compact
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "system_one/errors"
4
+ require_relative "system_one/question"
5
+ require_relative "system_one/answer"
6
+ require_relative "system_one/fake_backend"
7
+ require_relative "system_one/http_backend"
8
+ require_relative "system_one/client"
9
+ require_relative "system_one/log"
10
+ require_relative "system_one/record"
11
+ require_relative "system_one/similar"
12
+ require_relative "system_one/self_protect"
13
+ require_relative "system_one/approval_question"
14
+
15
+ module Samagotchi
16
+ # System 1: the cheap judgment model that will, one phase at a time,
17
+ # answer the guardrail asks a user always allows. P1 is the client and its
18
+ # settings; P2a judges every ask a user is about to see and logs the
19
+ # judgment and their answer (SystemOne::Log); P3a is `on` mode: a judgment
20
+ # that passes every gate allows the call once, without asking
21
+ # (SystemOne::Record, SystemOne::SelfProtect). The line a UI shows for an
22
+ # auto-allow is P3b (docs/guardrails.md, "System 1").
23
+ module SystemOne
24
+ end
25
+ end
@@ -1550,7 +1550,8 @@ module Samagotchi
1550
1550
  record = join_record(records, part)
1551
1551
  duration = record&.dig("duration_ms")
1552
1552
  @screen.commit(join_tool_line(part, duration_ms: duration.is_a?(Numeric) ? duration : nil,
1553
- saved_status: record&.dig("status")))
1553
+ saved_status: record&.dig("status"),
1554
+ auto_allowed: record&.dig("guardrail")))
1554
1555
  end
1555
1556
  end
1556
1557
  end
@@ -1594,8 +1595,10 @@ module Samagotchi
1594
1595
  # A saved call's row; "no result" for a call the turn never answered
1595
1596
  # (it ended first). Its status is the saved record's, as live and the
1596
1597
  # web's reload (a guardrail's "blocked" is no error), else read from
1597
- # its result (a guardrail's deny text reads blocked too).
1598
- def join_tool_line(part, duration_ms: nil, saved_status: nil)
1598
+ # its result (a guardrail's deny text reads blocked too). A record that
1599
+ # System 1 allowed once (+auto_allowed+, its saved {rule, p}) says so,
1600
+ # as the live row does.
1601
+ def join_tool_line(part, duration_ms: nil, saved_status: nil, auto_allowed: nil)
1599
1602
  output = part[:output]
1600
1603
  command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
1601
1604
  text = ToolActivity.saved_result_text(output) unless output.nil?
@@ -1612,7 +1615,15 @@ module Samagotchi
1612
1615
  params: part[:params], status: status,
1613
1616
  description: ToolView.description_title(part[:view] || {}) }
1614
1617
  "#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
1615
- "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}"
1618
+ "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}" \
1619
+ "#{auto_allowed_suffix(auto_allowed)}"
1620
+ end
1621
+
1622
+ # The live row's System 1 suffix for a saved record's {rule, p}.
1623
+ def auto_allowed_suffix(saved)
1624
+ return "" unless saved.is_a?(Hash)
1625
+
1626
+ format_tool_guardrail_suffix({ decided_by: "system one", p: saved["p"] || saved[:p] })
1616
1627
  end
1617
1628
 
1618
1629
  # The context notes that came since the last prompt (all of them in a
@@ -221,6 +221,7 @@ module Samagotchi
221
221
  line += @view.format_tool_image_suffix(images) if images&.any?
222
222
  line += @view.format_tool_diff_suffix(diff) if diff
223
223
  line += @view.format_tool_cut_suffix(output_truncated) if output_truncated
224
+ line += @view.format_tool_guardrail_suffix(activity[:guardrail]) if activity[:guardrail]
224
225
  @view.print_line(line)
225
226
  end
226
227
 
@@ -202,6 +202,20 @@ module Samagotchi
202
202
  output_truncated ? " #{paint("[cut]", 90)}" : ""
203
203
  end
204
204
 
205
+ # A call System 1 allowed once instead of asking: " · auto-allowed
206
+ # (System 1, p=0.97)", yellow, from the activity's guardrail (symbol or
207
+ # string keys: live, snapshot). Any other guardrail data adds nothing.
208
+ def format_tool_guardrail_suffix(guardrail)
209
+ return "" unless guardrail.is_a?(Hash)
210
+
211
+ get = ->(key) { guardrail[key] || guardrail[key.to_s] }
212
+ return "" unless get.call(:decided_by) == "system one"
213
+
214
+ p_value = get.call(:p)
215
+ detail = p_value.nil? ? "System 1" : format("System 1, p=%.2f", p_value.to_f)
216
+ " #{paint("\u00b7 auto-allowed (#{detail})", 33)}"
217
+ end
218
+
205
219
  # Prompt labels by the sender's client_id: a whole id, or a prefix
206
220
  # (ending in ":") it starts with (turn_events.js CLIENT_LABELS;
207
221
  # spec/shared/labels_matrix.json).
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.115.0"
4
+ VERSION = "0.116.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -247,6 +247,7 @@ module Samagotchi
247
247
  ["GET", %r{\A/api/sessions/([^/]+)/worker\z}, :handle_worker, :owner, :session],
248
248
  ["POST", %r{\A/api/sessions/([^/]+)/exit\z}, :handle_exit, :owner, :session],
249
249
  ["POST", %r{\A/api/sessions/([^/]+)/recap\z}, :handle_recap, :owner, :session],
250
+ ["POST", %r{\A/api/sessions/([^/]+)/guardrails/wrong\z}, :handle_system_one_wrong, :owner, :session],
250
251
  ["GET", %r{\A/api/sessions/([^/]+)/stats\z}, :handle_stats, :owner, :session],
251
252
  ["GET", %r{\A/api/sessions/([^/]+)/children/counts\z}, :handle_children_counts, :owner, :session],
252
253
  ["GET", %r{\A/api/sessions/([^/]+)/turns/([^/]+)/records\z}, :handle_turn_records, :owner, :session],
@@ -144,6 +144,16 @@ export function addCompleted(model, event = {}, now = null) {
144
144
  Object.assign(row, pickFields(event.activity, ACTIVITY_FIELDS));
145
145
  // A grep that found nothing (status ok): the row says "no match".
146
146
  if (event.activity?.no_match) row.no_match = true;
147
+ // System 1 allowed the call once instead of asking (the row's label); any
148
+ // other guardrail verdict draws nothing.
149
+ const guardrail = event.activity?.guardrail;
150
+ if (guardrail?.decided_by === "system one") {
151
+ row.guardrail = {
152
+ rule: guardrail.rule,
153
+ p: guardrail.p,
154
+ ...(guardrail.judgment_id ? { judgment_id: guardrail.judgment_id } : {}),
155
+ };
156
+ }
147
157
  // The images the tool returned (a reload in the turn view draws them from
148
158
  // the saved parts, turn_html.js toolRowTemplate).
149
159
  if (event.images?.length) row.images = event.images;
@@ -50,6 +50,7 @@ import { createGapClock, lastActivity } from "./time_gaps.js";
50
50
  import { createMarkdownPlace } from "./markdown_place.js";
51
51
  import { openerFill, openTitle, restoredComposer } from "./chains.js";
52
52
  import { createTaskStopper } from "./task_stop.js";
53
+ import { createSystemOneWrongAction } from "./system_one_wrong.js";
53
54
  import { appendQuote } from "./annotations.js";
54
55
  import { applyComposerText, applyRowTemplate, fetchApply } from "./review_apply.js";
55
56
  import { authorBadge, nameUnnamedBadges } from "./participant_names.js";
@@ -299,6 +300,8 @@ const { placeToast } = createDockReserve({
299
300
  function turnRoots() {
300
301
  return turnDom.root ? [historyEl, turnDom.root] : [historyEl];
301
302
  }
303
+ const systemOneWrongAction = createSystemOneWrongAction({ sessionId: () => selected });
304
+ turnRoots().forEach((root) => root.addEventListener("click", systemOneWrongAction));
302
305
  function findInTurnRoots(selector) {
303
306
  return turnRoots().flatMap((root) => [...root.querySelectorAll(selector)]);
304
307
  }
@@ -14,17 +14,17 @@ export function composerFolded({ saved, archivedVisit, visitOpen }) {
14
14
  return !!saved || !!archivedVisit;
15
15
  }
16
16
 
17
- // The composer's grip while it is the fold's handle. Expanded it only says
18
- // how to resize and hide; folded it is drawn as the handle on the info
19
- // bar's top edge, pulled up or clicked to open, and marked: `draft` (the
20
- // box holds text or images: folding never clears them: the accent),
21
- // `waiting` (the attention colour: the session waits on the user, and its
22
- // card may not take the focus while a draft is there). The title carries
23
- // the words.
17
+ // The composer's grip while it is the fold's handle. Expanded it says how to
18
+ // resize it and that a click hides it (a double-click resets the height);
19
+ // folded it is drawn as the handle on the info bar's top edge, pulled up or
20
+ // clicked to open, and marked: `draft` (the box holds text or images: folding
21
+ // never clears them: the accent), `waiting` (the attention colour: the session
22
+ // waits on the user, and its card may not take the focus while a draft is
23
+ // there). The title carries the words.
24
24
  // @return {title, folded, draft, waiting}
25
25
  export function foldHandle({ folded, draft, waiting }) {
26
26
  if (!folded) {
27
- return { title: "Drag to make the message box taller, down to hide it (double-click resets)", folded: false, draft: false, waiting: false };
27
+ return { title: "Drag to resize, click to hide (double-click resets)", folded: false, draft: false, waiting: false };
28
28
  }
29
29
  const marked = !!draft;
30
30
  let title = "Pull up or click to write";