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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -1
- data/bin/chi +2 -0
- data/docs/cli.md +5 -1
- data/docs/configuration.md +7 -0
- data/docs/guardrails.md +188 -1
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -0
- data/lib/samagotchi/bridge.rb +26 -0
- data/lib/samagotchi/bridge_client.rb +7 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +15 -0
- data/lib/samagotchi/engine.rb +6 -1
- data/lib/samagotchi/guardrail_wiring.rb +166 -2
- data/lib/samagotchi/guardrails/approval.rb +20 -0
- data/lib/samagotchi/guardrails/targets.rb +23 -2
- data/lib/samagotchi/guardrails/verdict.rb +23 -3
- data/lib/samagotchi/guardrails_command.rb +329 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +13 -0
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_commands.rb +79 -2
- data/lib/samagotchi/session_metrics.rb +7 -1
- data/lib/samagotchi/system_one/answer.rb +78 -0
- data/lib/samagotchi/system_one/approval_question.rb +113 -0
- data/lib/samagotchi/system_one/client.rb +89 -0
- data/lib/samagotchi/system_one/errors.rb +14 -0
- data/lib/samagotchi/system_one/fake_backend.rb +45 -0
- data/lib/samagotchi/system_one/http_backend.rb +129 -0
- data/lib/samagotchi/system_one/log.rb +133 -0
- data/lib/samagotchi/system_one/question.rb +86 -0
- data/lib/samagotchi/system_one/record.rb +83 -0
- data/lib/samagotchi/system_one/self_protect.rb +90 -0
- data/lib/samagotchi/system_one/similar.rb +69 -0
- data/lib/samagotchi/system_one.rb +25 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +15 -4
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +14 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +1 -0
- data/lib/samagotchi/web/public/activity.js +10 -0
- data/lib/samagotchi/web/public/app.js +3 -0
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/composer_fold.js +8 -8
- data/lib/samagotchi/web/public/composer_grip.js +65 -22
- data/lib/samagotchi/web/public/composer_size.js +30 -2
- data/lib/samagotchi/web/public/data.js +10 -0
- data/lib/samagotchi/web/public/favicon.svg +1 -1
- data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
- data/lib/samagotchi/web/public/index.html +5 -0
- data/lib/samagotchi/web/public/system_one_wrong.js +32 -0
- data/lib/samagotchi/web/public/timing.js +9 -5
- data/lib/samagotchi/web/public/turn_events.js +1 -0
- data/lib/samagotchi/web/public/turn_html.js +13 -2
- data/lib/samagotchi/web/session_control.rb +22 -0
- data/lib/samagotchi/web/session_show.rb +35 -5
- 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
|
-
|
|
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).
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
}
|
|
Binary file
|
|
@@ -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
|
|
18
|
-
//
|
|
19
|
-
// bar's top edge, pulled up or
|
|
20
|
-
// box holds text or images: folding
|
|
21
|
-
// `waiting` (the attention colour: the session
|
|
22
|
-
// card may not take the focus while a draft is
|
|
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
|
|
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";
|