samagotchi 0.115.0 → 0.117.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 +76 -1
- data/bin/chi +2 -0
- data/docs/cli.md +33 -2
- data/docs/configuration.md +13 -0
- data/docs/guardrails.md +188 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/sessions.md +3 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +4 -0
- data/lib/samagotchi/bridge.rb +114 -14
- data/lib/samagotchi/bridge_client.rb +20 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +34 -3
- 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/hold_stop.rb +89 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +13 -0
- data/lib/samagotchi/prompt_warmup.rb +8 -3
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/web_bridge_client.rb +9 -4
- data/lib/samagotchi/session_commands.rb +79 -2
- data/lib/samagotchi/session_manager.rb +3 -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/task_watch.rb +23 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +145 -12
- 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 +3 -0
- data/lib/samagotchi/web/public/activity.js +10 -0
- data/lib/samagotchi/web/public/app.js +20 -1
- 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 +40 -5
- data/lib/samagotchi/web/public/data.js +24 -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 +21 -0
- data/lib/samagotchi/web/public/session_actions.js +39 -6
- data/lib/samagotchi/web/public/stop_sheet.js +126 -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_access_routes.rb +9 -6
- data/lib/samagotchi/web/session_control.rb +62 -3
- data/lib/samagotchi/web/session_show.rb +35 -5
- data/lib/samagotchi/worker.rb +57 -5
- data/lib/samagotchi/worker_idle_exit.rb +111 -9
- metadata +17 -1
|
@@ -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
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
3
5
|
require_relative "config"
|
|
4
6
|
require_relative "log"
|
|
5
7
|
require_relative "tools/task_runtime"
|
|
@@ -182,19 +184,35 @@ module Samagotchi
|
|
|
182
184
|
# @return [Array<Entry>]
|
|
183
185
|
def holding = entries.reject { |entry| entry.note_only || entry.failures >= MAX_FAILURES }
|
|
184
186
|
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
|
|
187
|
+
# A watched task still running, as /exit and the exit ask see it:
|
|
188
|
+
# +age_seconds+, the seconds since it started (nil when its record has
|
|
189
|
+
# no start time).
|
|
190
|
+
Running = Data.define(:id, :command, :age_seconds)
|
|
191
|
+
|
|
192
|
+
# The #holding tasks still running, for /exit (WorkerIdleExit's
|
|
193
|
+
# watched_running): one finished meanwhile is noted as the worker leaves.
|
|
194
|
+
# One `ps` per task.
|
|
195
|
+
# @return [Array<Running>]
|
|
189
196
|
def running
|
|
190
197
|
holding.filter_map do |entry|
|
|
191
198
|
record, = Tools::TaskRuntime.get_record(entry.id)
|
|
192
|
-
|
|
199
|
+
next unless record&.running?
|
|
200
|
+
|
|
201
|
+
Running.new(id: entry.id, command: record.command.to_s, age_seconds: age_seconds(record))
|
|
193
202
|
end
|
|
194
203
|
end
|
|
195
204
|
|
|
196
205
|
private
|
|
197
206
|
|
|
207
|
+
# Seconds since the task started, from its record's iso8601 +started_at+;
|
|
208
|
+
# nil when that can't be read.
|
|
209
|
+
def age_seconds(record)
|
|
210
|
+
started = Time.parse(record.started_at.to_s)
|
|
211
|
+
[(Time.now - started).round, 0].max
|
|
212
|
+
rescue ArgumentError, TypeError
|
|
213
|
+
nil
|
|
214
|
+
end
|
|
215
|
+
|
|
198
216
|
# Removes +entry+; nil (a filter_map's skip), or with +kept+ whether it
|
|
199
217
|
# was there. +log+: why a task leaves without a note, at +level+ (debug
|
|
200
218
|
# by default), with +error+ when there is one.
|
|
@@ -57,17 +57,35 @@ module Samagotchi
|
|
|
57
57
|
# An exit the worker agreed to in an empty session. Said before it has
|
|
58
58
|
# left: it checks again once it lets go, and a note or prompt arriving
|
|
59
59
|
# first keeps the session (SessionManager.discard_left_session).
|
|
60
|
-
|
|
60
|
+
EMPTY_WORDS = "the session is empty: it will be discarded if nothing arrives before its worker stops."
|
|
61
|
+
EMPTY_EXIT = "Detached; #{EMPTY_WORDS}"
|
|
61
62
|
# Why the worker stayed up after /exit, by the reason it gave.
|
|
62
63
|
HELD_REASONS = {
|
|
63
64
|
"turn_running" => "a turn is running", "input_queued" => "prompts are queued",
|
|
64
65
|
"continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
|
|
65
66
|
"reminders" => "reminders are set", "starting" => "the worker is still starting",
|
|
66
|
-
"background_tasks" => "background tasks it started are still running"
|
|
67
|
+
"background_tasks" => "background tasks it started are still running",
|
|
68
|
+
"delegates" => "delegates of this session are still running"
|
|
67
69
|
}.freeze
|
|
68
70
|
# How to end a background task that holds the worker up (an older
|
|
69
71
|
# worker's 409 has no hint of its own).
|
|
70
72
|
TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
|
|
73
|
+
# The /exit ask, when every hold is one the user can stop: what still
|
|
74
|
+
# runs, then the three answers. Each kind, singular and plural.
|
|
75
|
+
EXIT_HOLD_WORDS = {
|
|
76
|
+
"reminders" => %w[reminder reminders],
|
|
77
|
+
"background_tasks" => ["background task", "background tasks"],
|
|
78
|
+
"delegates" => %w[delegate delegates]
|
|
79
|
+
}.freeze
|
|
80
|
+
# The 200's +stopped+ keys, by the kind the ask lists them under.
|
|
81
|
+
STOPPED_KEYS = { "reminders" => "reminders", "tasks" => "background_tasks", "delegates" => "delegates" }.freeze
|
|
82
|
+
EXIT_ASK_OPTIONS = ["Keep running in the background (detach)", "Stop them and exit", "Back to the session"].freeze
|
|
83
|
+
# How the ask reads an answer.
|
|
84
|
+
EXIT_ANSWERS = {
|
|
85
|
+
"1" => :keep, "keep" => :keep, "detach" => :keep, "background" => :keep,
|
|
86
|
+
"2" => :stop, "stop" => :stop, "exit" => :stop,
|
|
87
|
+
"3" => :back, "back" => :back, "stay" => :back
|
|
88
|
+
}.freeze
|
|
71
89
|
# How much of the last answer a join shows.
|
|
72
90
|
JOIN_ANSWER_LINES = 12
|
|
73
91
|
JOIN_ANSWER_CHARS = 1200
|
|
@@ -175,6 +193,8 @@ module Samagotchi
|
|
|
175
193
|
@own_failed = false
|
|
176
194
|
# A continue offer is pending: the prompt asks for the answer.
|
|
177
195
|
@continue_offer = nil
|
|
196
|
+
# The /exit ask is open: the next line is its answer (#answer_exit_ask).
|
|
197
|
+
@exit_ask = nil
|
|
178
198
|
# The status row: the worker's model, ctx, the session's memories.
|
|
179
199
|
@status = StatusRow.new(screen)
|
|
180
200
|
# Participants' names by author ("p:<id>"), from the join's state
|
|
@@ -470,6 +490,14 @@ module Samagotchi
|
|
|
470
490
|
|
|
471
491
|
# @return [Symbol, nil] :detach to end the loop
|
|
472
492
|
def submit(line)
|
|
493
|
+
if @exit_ask
|
|
494
|
+
# The exit ask is open: this line answers it. Ctrl-D and /detach
|
|
495
|
+
# detach without stopping anything, the way its 1 does.
|
|
496
|
+
return detach(kept_line(@exit_ask)) if line.nil? || command_registry.lookup_local(line.strip)&.id == :detach
|
|
497
|
+
|
|
498
|
+
return answer_exit_ask(line)
|
|
499
|
+
end
|
|
500
|
+
|
|
473
501
|
return detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if line.nil?
|
|
474
502
|
|
|
475
503
|
text = line.strip
|
|
@@ -599,14 +627,104 @@ module Samagotchi
|
|
|
599
627
|
|
|
600
628
|
# Detach, and ask the worker to exit now. The worker decides: it stays
|
|
601
629
|
# up while anything still needs it (a turn, queued input, a continue
|
|
602
|
-
# offer, another UI, reminders), and the line says which.
|
|
603
|
-
#
|
|
630
|
+
# offer, another UI, reminders), and the line says which. When the only
|
|
631
|
+
# holds are ones the user can stop, it asks what to do with them instead
|
|
632
|
+
# of detaching (#answer_exit_ask).
|
|
633
|
+
# @return [Symbol, nil] :detach, or nil to stay at the prompt
|
|
604
634
|
def exit_worker
|
|
605
|
-
|
|
635
|
+
reply = @client.request_exit(client_id: @client_id)
|
|
636
|
+
holds = reply.status == 409 ? reply.json&.fetch("holds", nil) : nil
|
|
637
|
+
return detach(exit_line(reply)) unless holds.is_a?(Array) && !holds.empty?
|
|
638
|
+
|
|
639
|
+
@exit_ask = holds
|
|
640
|
+
@screen.commit(ask_lines(holds))
|
|
641
|
+
nil
|
|
606
642
|
rescue SystemCallError, IOError => e
|
|
607
643
|
detach(exit_failed_line(e.message))
|
|
608
644
|
end
|
|
609
645
|
|
|
646
|
+
# The ask: what still runs, then the three answers.
|
|
647
|
+
def ask_lines(holds)
|
|
648
|
+
lines = holds.map do |hold|
|
|
649
|
+
items = Array(hold["items"])
|
|
650
|
+
singular, plural = hold_words(hold["kind"])
|
|
651
|
+
" • #{items.size} #{items.size == 1 ? singular : plural}: #{items.map { |item| item_word(item, hold["kind"]) }.join(", ")}"
|
|
652
|
+
end
|
|
653
|
+
lines += EXIT_ASK_OPTIONS.each_with_index.map { |option, index| " #{index + 1}. #{option}" }
|
|
654
|
+
(["This session still has:"] + lines).join("\n")
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# One entry of a hold: the command or name, with its detail in
|
|
658
|
+
# brackets; a delegate leads with its short id and its title in quotes.
|
|
659
|
+
def item_word(item, kind)
|
|
660
|
+
label = item["label"].to_s
|
|
661
|
+
label = "#{item["id"].to_s[0, 8]} \"#{label}\"" if kind == "delegates"
|
|
662
|
+
detail = item["detail"].to_s
|
|
663
|
+
detail.empty? ? label : "#{label} (#{detail})"
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# The answer to the exit ask.
|
|
667
|
+
# @return [Symbol, nil] :detach for 1 and 2, nil for 3 or a line the
|
|
668
|
+
# ask doesn't understand
|
|
669
|
+
def answer_exit_ask(line)
|
|
670
|
+
choice = EXIT_ANSWERS[line.to_s.strip.downcase]
|
|
671
|
+
unless choice
|
|
672
|
+
@screen.commit("Say 1 (keep running), 2 (stop them and exit) or 3 (back to the session).")
|
|
673
|
+
return nil
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
holds = @exit_ask
|
|
677
|
+
@exit_ask = nil
|
|
678
|
+
case choice
|
|
679
|
+
when :keep then detach(kept_line(holds))
|
|
680
|
+
when :stop then stop_and_exit
|
|
681
|
+
else
|
|
682
|
+
@screen.commit("(nothing stopped; the worker keeps running)")
|
|
683
|
+
nil
|
|
684
|
+
end
|
|
685
|
+
end
|
|
686
|
+
|
|
687
|
+
# 1: detach, everything left running.
|
|
688
|
+
def kept_line(holds)
|
|
689
|
+
"Detached; the session keeps running (#{hold_words_list(holds)}). " \
|
|
690
|
+
"Re-attach with: #{via.attach(@client.session_id)}"
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# 2: ask the worker to stop them all and exit.
|
|
694
|
+
# @return [Symbol] :detach
|
|
695
|
+
def stop_and_exit
|
|
696
|
+
reply = @client.request_exit(client_id: @client_id, stop: true)
|
|
697
|
+
detach(exit_line(reply))
|
|
698
|
+
rescue SystemCallError, IOError => e
|
|
699
|
+
detach(exit_failed_line(e.message))
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# "2 reminders, 1 background task, 1 delegate" from the ask's holds.
|
|
703
|
+
def hold_words_list(holds)
|
|
704
|
+
holds.map do |hold|
|
|
705
|
+
items = Array(hold["items"])
|
|
706
|
+
singular, plural = hold_words(hold["kind"])
|
|
707
|
+
"#{items.size} #{items.size == 1 ? singular : plural}"
|
|
708
|
+
end.join(", ")
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def hold_words(kind) = EXIT_HOLD_WORDS.fetch(kind.to_s, [kind.to_s, kind.to_s])
|
|
712
|
+
|
|
713
|
+
# What a stop-all exit stopped: "stopped 2 reminders (api_health, git
|
|
714
|
+
# push), 1 background task (rails s)"; nil when it stopped nothing.
|
|
715
|
+
def stopped_words(stopped)
|
|
716
|
+
return nil unless stopped.is_a?(Hash)
|
|
717
|
+
|
|
718
|
+
parts = STOPPED_KEYS.filter_map do |key, kind|
|
|
719
|
+
names = Array(stopped[key])
|
|
720
|
+
next if names.empty?
|
|
721
|
+
|
|
722
|
+
singular, plural = hold_words(kind)
|
|
723
|
+
"#{names.size} #{names.size == 1 ? singular : plural} (#{names.join(", ")})"
|
|
724
|
+
end
|
|
725
|
+
parts.empty? ? nil : "stopped #{parts.join(", ")}"
|
|
726
|
+
end
|
|
727
|
+
|
|
610
728
|
# /exit --delete: the worker exits as for /exit, then the session is
|
|
611
729
|
# deleted. When the worker stays up (another UI, queued input, ...),
|
|
612
730
|
# nothing is deleted.
|
|
@@ -656,7 +774,9 @@ module Samagotchi
|
|
|
656
774
|
# (SessionManager.archive_session). An empty session: the worker
|
|
657
775
|
# deletes it as it leaves.
|
|
658
776
|
def exit_and_archive
|
|
659
|
-
|
|
777
|
+
# /archive can't ask about what still runs: a child the archive will
|
|
778
|
+
# stop is not worth a question here.
|
|
779
|
+
reply = @client.request_exit(client_id: @client_id, ask: false)
|
|
660
780
|
id = @client.session_id
|
|
661
781
|
return detach(EMPTY_EXIT) if reply.status == 200 && discards?(reply)
|
|
662
782
|
return detach("#{exit_line(reply)} Not archived.") unless [200, 409].include?(reply.status)
|
|
@@ -676,9 +796,11 @@ module Samagotchi
|
|
|
676
796
|
error = reply.json&.fetch("error", nil)
|
|
677
797
|
case reply.status
|
|
678
798
|
when 200
|
|
679
|
-
|
|
799
|
+
stopped = stopped_words(reply.json&.fetch("stopped", nil))
|
|
800
|
+
return EMPTY_EXIT if discards?(reply) && stopped.nil?
|
|
801
|
+
return "Detached; #{stopped}. #{EMPTY_WORDS.capitalize}" if discards?(reply)
|
|
680
802
|
|
|
681
|
-
"Detached; the session's worker is stopping. Resume with: #{via.resume(id)}"
|
|
803
|
+
"Detached; #{stopped || "the session's worker is stopping"}. Resume with: #{via.resume(id)}"
|
|
682
804
|
when 409
|
|
683
805
|
reason = reply.json&.fetch("reason", nil).to_s
|
|
684
806
|
"Detached; the session keeps running (#{held_words(reason, reply.json&.fetch("tasks", nil))}). " \
|
|
@@ -1550,7 +1672,8 @@ module Samagotchi
|
|
|
1550
1672
|
record = join_record(records, part)
|
|
1551
1673
|
duration = record&.dig("duration_ms")
|
|
1552
1674
|
@screen.commit(join_tool_line(part, duration_ms: duration.is_a?(Numeric) ? duration : nil,
|
|
1553
|
-
saved_status: record&.dig("status")
|
|
1675
|
+
saved_status: record&.dig("status"),
|
|
1676
|
+
auto_allowed: record&.dig("guardrail")))
|
|
1554
1677
|
end
|
|
1555
1678
|
end
|
|
1556
1679
|
end
|
|
@@ -1594,8 +1717,10 @@ module Samagotchi
|
|
|
1594
1717
|
# A saved call's row; "no result" for a call the turn never answered
|
|
1595
1718
|
# (it ended first). Its status is the saved record's, as live and the
|
|
1596
1719
|
# 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
|
-
|
|
1720
|
+
# its result (a guardrail's deny text reads blocked too). A record that
|
|
1721
|
+
# System 1 allowed once (+auto_allowed+, its saved {rule, p}) says so,
|
|
1722
|
+
# as the live row does.
|
|
1723
|
+
def join_tool_line(part, duration_ms: nil, saved_status: nil, auto_allowed: nil)
|
|
1599
1724
|
output = part[:output]
|
|
1600
1725
|
command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
|
|
1601
1726
|
text = ToolActivity.saved_result_text(output) unless output.nil?
|
|
@@ -1612,7 +1737,15 @@ module Samagotchi
|
|
|
1612
1737
|
params: part[:params], status: status,
|
|
1613
1738
|
description: ToolView.description_title(part[:view] || {}) }
|
|
1614
1739
|
"#{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])}"
|
|
1740
|
+
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}" \
|
|
1741
|
+
"#{auto_allowed_suffix(auto_allowed)}"
|
|
1742
|
+
end
|
|
1743
|
+
|
|
1744
|
+
# The live row's System 1 suffix for a saved record's {rule, p}.
|
|
1745
|
+
def auto_allowed_suffix(saved)
|
|
1746
|
+
return "" unless saved.is_a?(Hash)
|
|
1747
|
+
|
|
1748
|
+
format_tool_guardrail_suffix({ decided_by: "system one", p: saved["p"] || saved[:p] })
|
|
1616
1749
|
end
|
|
1617
1750
|
|
|
1618
1751
|
# 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).
|