samagotchi 0.146.0 → 0.148.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 +34 -1
- data/README.md +2 -2
- data/docs/architecture.md +14 -5
- data/docs/bundles.md +26 -16
- data/docs/cli.md +6 -2
- data/docs/configuration.md +19 -4
- data/docs/guardrails.md +25 -4
- data/docs/sessions.md +2 -2
- data/docs/system-one.md +1 -1
- data/lib/samagotchi/answer_tail.rb +1 -1
- data/lib/samagotchi/bundles/coordinator/guardrails/merge.yml +21 -0
- data/lib/samagotchi/bundles/coordinator/manifest.yml +7 -5
- data/lib/samagotchi/bundles/coordinator/plugin.rb +2 -1
- data/lib/samagotchi/bundles/coordinator/skill_coordinator.md +35 -13
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +5 -0
- data/lib/samagotchi/guardrail_wiring.rb +11 -6
- data/lib/samagotchi/guardrails/approvals.rb +3 -1
- data/lib/samagotchi/guardrails/rules.rb +6 -1
- data/lib/samagotchi/guardrails/verdict.rb +21 -1
- data/lib/samagotchi/guardrails_command.rb +47 -3
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session_chain.rb +91 -8
- data/lib/samagotchi/session_commands.rb +1 -1
- data/lib/samagotchi/session_continue.rb +3 -2
- data/lib/samagotchi/system_one/record.rb +17 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +1 -1
- data/lib/samagotchi/web/auto_update.rb +60 -5
- data/lib/samagotchi/web/public/app.js +5 -16
- data/lib/samagotchi/web/public/update_notice.js +22 -11
- data/lib/samagotchi/web/public/update_sheet.js +4 -1
- data/lib/samagotchi/web/session_access_routes.rb +1 -1
- data/lib/samagotchi/web/session_hub.rb +17 -4
- metadata +2 -1
|
@@ -323,7 +323,7 @@ module Samagotchi
|
|
|
323
323
|
# one that passes every gate is allowed once without reaching them
|
|
324
324
|
# (docs/guardrails.md, "System 1").
|
|
325
325
|
judgment = begin
|
|
326
|
-
system_one_judge(payload, targets: verdict.targets)
|
|
326
|
+
system_one_judge(payload, targets: verdict.targets, ask_user: verdict.fresh_ask? || verdict.second_rule_ask?)
|
|
327
327
|
rescue LLM::RequestCancelled
|
|
328
328
|
return turn_stopped(verdict)
|
|
329
329
|
end
|
|
@@ -366,8 +366,9 @@ module Samagotchi
|
|
|
366
366
|
# auto-allowed: its asks go to the owner. No client (mode off, no
|
|
367
367
|
# host) means no line at all. A judgment that fails is logged with p nil,
|
|
368
368
|
# so the failures are in the log too.
|
|
369
|
+
# With +ask_user+ (a fresh ask, a second rule's) it is judged and logged, never answered.
|
|
369
370
|
# @return [Hash, nil] {id:, p:, auto:}, nil when nothing was written
|
|
370
|
-
def system_one_judge(payload, targets: nil)
|
|
371
|
+
def system_one_judge(payload, targets: nil, ask_user: false)
|
|
371
372
|
return nil if Steer.participant_author?(turn_author)
|
|
372
373
|
|
|
373
374
|
client = @system_one.call
|
|
@@ -397,13 +398,13 @@ module Samagotchi
|
|
|
397
398
|
end
|
|
398
399
|
latency_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round
|
|
399
400
|
p = answers && answers[SystemOne::ApprovalQuestion::ID]&.noul
|
|
400
|
-
auto = system_one_auto_allow?(client, approval, p, entries, targets: targets)
|
|
401
|
+
auto = !ask_user && system_one_auto_allow?(client, approval, p, entries, targets: targets)
|
|
401
402
|
decision = system_one_decision(client, auto)
|
|
402
403
|
|
|
403
404
|
id = log.judgment(id: run_id, session_id: @session_lookup.call&.id,
|
|
404
405
|
rule: approval.rule, source: approval.source, tool: approval.tool,
|
|
405
406
|
**SystemOne::ApprovalQuestion.call_fields(approval),
|
|
406
|
-
repo_name: approval.repo_name,
|
|
407
|
+
repo_name: approval.repo_name, repo: targets&.repo,
|
|
407
408
|
p: p, threshold: Config.get("guardrails.system_one.threshold"), model: client.model,
|
|
408
409
|
latency_ms: latency_ms, decision: decision, origin: "live")
|
|
409
410
|
# A judgment the log cannot hold is no judgment: the user is asked.
|
|
@@ -443,7 +444,8 @@ module Samagotchi
|
|
|
443
444
|
|
|
444
445
|
# Every gate an auto-allow must pass, in order: the mode, the rule the
|
|
445
446
|
# user opted in, the never-list and chi's own files, the probability, and
|
|
446
|
-
# the rule's record
|
|
447
|
+
# the rule's record (this repo's for a rule that goes by repo; with no repo
|
|
448
|
+
# that is none). Any one that fails leaves the ask to the user. The
|
|
447
449
|
# entries are the judgment log read once for this ask.
|
|
448
450
|
# @param approval [Guardrails::ApprovalFacts] the ask's facts
|
|
449
451
|
# @param entries [Array<Hash>] SystemOne::Log#entries
|
|
@@ -460,7 +462,10 @@ module Samagotchi
|
|
|
460
462
|
return false if p.nil? || p.to_f < threshold
|
|
461
463
|
|
|
462
464
|
min_record = Config.get("guardrails.system_one.min_record").to_i
|
|
463
|
-
SystemOne::Record.
|
|
465
|
+
per_repo = SystemOne::Record.per_repo?(rule)
|
|
466
|
+
return false if per_repo && targets&.repo.nil?
|
|
467
|
+
|
|
468
|
+
SystemOne::Record.new(entries, rule: rule, repo: (targets.repo if per_repo)).qualifies?(threshold: threshold, min_record: min_record)
|
|
464
469
|
rescue StandardError => e
|
|
465
470
|
Samagotchi::Log.warn(:guardrails, "system_one_gate_failed", error: "#{e.class}: #{e.message}")
|
|
466
471
|
false
|
|
@@ -80,7 +80,9 @@ module Samagotchi
|
|
|
80
80
|
case e["scope"]
|
|
81
81
|
when "session" then session_id && e["session_id"] == session_id && e["key"] == key
|
|
82
82
|
when "repo" then here.call(e) && e["key"] == key
|
|
83
|
-
when "rule"
|
|
83
|
+
when "rule"
|
|
84
|
+
# One rule's grant can't answer for a second rule's ask on the call.
|
|
85
|
+
!verdict.second_rule_ask? && verdict.rule && e["rule"] == verdict.rule && e["source"] == verdict.source && here.call(e)
|
|
84
86
|
end
|
|
85
87
|
end
|
|
86
88
|
end
|
|
@@ -310,10 +310,13 @@ module Samagotchi
|
|
|
310
310
|
@disable.reject { |entry| @rules.any? { |rule| disables?(entry, rule) } }
|
|
311
311
|
end
|
|
312
312
|
|
|
313
|
-
# A core check: every matching rule votes (strictest wins).
|
|
313
|
+
# A core check: every matching rule votes (strictest wins). A second
|
|
314
|
+
# rule id that asks takes rule grants out (Verdict#second_rule_asks!);
|
|
315
|
+
# asks from before the rules (the child boundary's) stay as they are.
|
|
314
316
|
def check(verdict)
|
|
315
317
|
return verdict unless @enabled
|
|
316
318
|
|
|
319
|
+
asking = nil
|
|
317
320
|
@rules.each do |rule|
|
|
318
321
|
next if disabled?(rule)
|
|
319
322
|
next unless rule.for_mode?(@mode)
|
|
@@ -323,6 +326,8 @@ module Samagotchi
|
|
|
323
326
|
verdict.deny!(rule.reason, rule: rule.id, source: rule.source, decided_by: "rule")
|
|
324
327
|
else
|
|
325
328
|
verdict.ask!(rule.reason, scopes: rule.scopes, rule: rule.id, source: rule.source, decided_by: "rule")
|
|
329
|
+
verdict.second_rule_asks!(rule.scopes) if asking && asking != rule.id
|
|
330
|
+
asking ||= rule.id
|
|
326
331
|
end
|
|
327
332
|
end
|
|
328
333
|
verdict
|
|
@@ -8,7 +8,8 @@ module Samagotchi
|
|
|
8
8
|
# first one stays, except that a protected rule's ask (PROTECTED_RULES)
|
|
9
9
|
# takes over an unprotected ask: its rule id is what a parent's answer
|
|
10
10
|
# and a stored approval are checked against, and the offered scopes
|
|
11
|
-
# narrow to both asks' common ones.
|
|
11
|
+
# narrow to both asks' common ones. A second rule's ask takes rule grants
|
|
12
|
+
# out (#second_rule_asks!): the one id it keeps can't answer for both.
|
|
12
13
|
class Verdict
|
|
13
14
|
STRICTNESS = { allow: 0, ask: 1, deny: 2 }.freeze
|
|
14
15
|
# Rules that ask about chi's own config and hooks: the core
|
|
@@ -47,6 +48,9 @@ module Samagotchi
|
|
|
47
48
|
|
|
48
49
|
def fresh_ask? = @fresh_ask == true
|
|
49
50
|
|
|
51
|
+
# Whether a second distinct rule asks about this call (#second_rule_asks!).
|
|
52
|
+
def second_rule_ask? = @second_rule_ask == true
|
|
53
|
+
|
|
50
54
|
def initialize(call:)
|
|
51
55
|
@call = call
|
|
52
56
|
@decision = :allow
|
|
@@ -84,6 +88,22 @@ module Samagotchi
|
|
|
84
88
|
self
|
|
85
89
|
end
|
|
86
90
|
|
|
91
|
+
# A second distinct rule asks about this call (Rules#check). The ask
|
|
92
|
+
# names one rule, and a rule grant or System 1's record is about that
|
|
93
|
+
# rule alone, so neither answers for the other's: no rule-scope entry
|
|
94
|
+
# matches it (Approvals.find_in), System 1 doesn't auto-allow it, and it
|
|
95
|
+
# offers what both rules allow, minus the rule. An exact-call entry
|
|
96
|
+
# (session, repo) still answers: it is keyed on the whole command.
|
|
97
|
+
# @param scopes [Array<String>, nil] the second rule's scopes (default all)
|
|
98
|
+
def second_rule_asks!(scopes = nil)
|
|
99
|
+
@second_rule_ask = true
|
|
100
|
+
allowed = Array(scopes).map(&:to_s) & SCOPES
|
|
101
|
+
allowed = SCOPES if allowed.empty?
|
|
102
|
+
left = (@scopes & allowed) - %w[rule]
|
|
103
|
+
@scopes = left.empty? ? %w[once] : left
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
87
107
|
def self.protected_rule?(rule) = PROTECTED_RULES.include?(rule.to_s)
|
|
88
108
|
|
|
89
109
|
# A before_tool_call hook set the old event[:blocked] flag. Its model
|
|
@@ -8,6 +8,7 @@ require_relative "cli/flags"
|
|
|
8
8
|
require_relative "config"
|
|
9
9
|
require_relative "bridge/card_store"
|
|
10
10
|
require_relative "guardrails/approval"
|
|
11
|
+
require_relative "guardrail_wiring"
|
|
11
12
|
require_relative "guardrails/approvals"
|
|
12
13
|
require_relative "guardrails/parent_policy"
|
|
13
14
|
require_relative "host_registry"
|
|
@@ -20,7 +21,10 @@ module Samagotchi
|
|
|
20
21
|
# seed: System 1 judges every approval this machine's sessions already
|
|
21
22
|
# answered, and the judgment and the user's answer go to the log as they
|
|
22
23
|
# would live. A rule's record then starts with the asks the user really
|
|
23
|
-
# decided on, instead of waiting for a few days of live ones.
|
|
24
|
+
# decided on, instead of waiting for a few days of live ones. `rule` says
|
|
25
|
+
# whether one rule votes `ask` on this machine now, read from the config and
|
|
26
|
+
# the installed bundles as a worker reads them (GuardrailWiring#rules), by
|
|
27
|
+
# its id: the text of a command never comes near it.
|
|
24
28
|
class GuardrailsCommand
|
|
25
29
|
include CLI::Command
|
|
26
30
|
|
|
@@ -41,16 +45,24 @@ module Samagotchi
|
|
|
41
45
|
--dry-run judges and prints the table without writing a line. Needs
|
|
42
46
|
guardrails.system_one.mode audit or on and a system_one.host
|
|
43
47
|
(exit 2 without one).
|
|
48
|
+
rule <id>
|
|
49
|
+
Print exactly "asks" when the rule is loaded, not disabled and votes
|
|
50
|
+
ask in this guardrails.mode for every model; another line says why not
|
|
51
|
+
when it isn't (exit 0 either way). Reads config.yml and the installed
|
|
52
|
+
bundles, as a worker does: no session, no model, nothing written.
|
|
44
53
|
TEXT
|
|
45
54
|
|
|
46
|
-
SUBCOMMANDS = %w[replay].freeze
|
|
55
|
+
SUBCOMMANDS = %w[replay rule].freeze
|
|
47
56
|
FLAGS = {
|
|
48
57
|
"replay" => CLI::Flags.new(help: HELP_WORDS, args: false) do |f|
|
|
49
58
|
f.switch "--dry-run"
|
|
50
59
|
f.value "--threshold"
|
|
51
60
|
f.value "--model"
|
|
52
|
-
end
|
|
61
|
+
end,
|
|
62
|
+
"rule" => CLI::Flags.new(help: HELP_WORDS)
|
|
53
63
|
}.freeze
|
|
64
|
+
# What `rule` prints when a rule asks. An agent compares the line to it.
|
|
65
|
+
ASKS = "asks"
|
|
54
66
|
# The labels that mean the user let the call through.
|
|
55
67
|
ALLOW_LABELS = %w[allow_once allow_wide].freeze
|
|
56
68
|
# replay's table, in column order: the rule, how many asks it had, how
|
|
@@ -135,6 +147,8 @@ module Samagotchi
|
|
|
135
147
|
parsed = parse_flags(FLAGS.fetch(@sub), @argv[1..])
|
|
136
148
|
return parsed if parsed.is_a?(Integer)
|
|
137
149
|
|
|
150
|
+
return rule(parsed.args) if @sub == "rule"
|
|
151
|
+
|
|
138
152
|
replay(parsed.options)
|
|
139
153
|
end
|
|
140
154
|
|
|
@@ -142,6 +156,36 @@ module Samagotchi
|
|
|
142
156
|
|
|
143
157
|
def command_name = @sub ? "chi guardrails #{@sub}" : "chi guardrails"
|
|
144
158
|
|
|
159
|
+
def rule(args)
|
|
160
|
+
return usage_error("give one rule id") unless args.size == 1
|
|
161
|
+
|
|
162
|
+
@stdout.puts(rule_status(args.first))
|
|
163
|
+
CLI::Exit::OK
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# The rule set as a worker would load it now, the rule's id looked up in
|
|
167
|
+
# it. Every other answer than ASKS names what stands in the way.
|
|
168
|
+
def rule_status(id)
|
|
169
|
+
wiring = GuardrailWiring.new(scratch: false, hooks: -> {}, tools: -> {}, session: -> {}, model_key: -> {},
|
|
170
|
+
cancelled: -> { false }, ask: ->(_fields) {})
|
|
171
|
+
rules = wiring.rules
|
|
172
|
+
failed = wiring.failures.required
|
|
173
|
+
return "rules failed to load: #{failed.map(&:what).join(", ")}" unless failed.empty?
|
|
174
|
+
return "guardrails are off (guardrails.enabled: false)" unless rules.enabled?
|
|
175
|
+
|
|
176
|
+
named = rules.rules.select { |r| r.id == id }
|
|
177
|
+
return "no rule #{id} is loaded" if named.empty?
|
|
178
|
+
|
|
179
|
+
enabled = named.reject { |r| rules.disabled?(r) }
|
|
180
|
+
return "rule #{id} is disabled (guardrails.disable)" if enabled.empty?
|
|
181
|
+
|
|
182
|
+
voting = enabled.select { |r| r.for_mode?(rules.mode) }
|
|
183
|
+
return "rule #{id} doesn't vote in guardrails.mode #{rules.mode}" if voting.empty?
|
|
184
|
+
return "rule #{id} denies" if voting.any? { |r| r.verdict == :deny }
|
|
185
|
+
|
|
186
|
+
voting.any? { |r| r.models.nil? } ? ASKS : "rule #{id} asks for some models only"
|
|
187
|
+
end
|
|
188
|
+
|
|
145
189
|
def replay(options)
|
|
146
190
|
threshold = threshold(options[:threshold])
|
|
147
191
|
return usage_error(threshold) if threshold.is_a?(String)
|
|
@@ -207,6 +207,7 @@ module Samagotchi
|
|
|
207
207
|
chi memory <ls|rm> [--scope project|system] the memories; rm moves one to the bundle trash
|
|
208
208
|
chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try>
|
|
209
209
|
chi guardrails replay [--dry-run] [--threshold T] [--model M] seed System 1's record from answered approvals
|
|
210
|
+
chi guardrails rule <id> print "asks" if that rule votes ask on this machine now
|
|
210
211
|
chi judgments ls | report NAME System 1's saved asks on this machine
|
|
211
212
|
chi toolkit report [--days N] grade toolkit suggestions against the tools sessions used
|
|
212
213
|
chi desktop <install|upgrade|uninstall|status> macOS "Send to chi" helper
|
|
@@ -76,7 +76,8 @@ module Samagotchi
|
|
|
76
76
|
in its folder, on its model (or --model's; one whose
|
|
77
77
|
host is gone falls back to the default, with a
|
|
78
78
|
warning), LLM context, thinking level, toolkit and
|
|
79
|
-
bundle settings, with a note carrying its recap
|
|
79
|
+
bundle settings, with a note carrying its recap and
|
|
80
|
+
last answer; it
|
|
80
81
|
is archived.
|
|
81
82
|
last:ID names the chain's latest link.
|
|
82
83
|
Without -m the new session waits idle. Refused for a
|
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "time"
|
|
4
|
+
require_relative "answer_tail"
|
|
5
|
+
require_relative "config"
|
|
6
|
+
require_relative "log"
|
|
7
|
+
require_relative "output_formatter"
|
|
4
8
|
require_relative "session"
|
|
9
|
+
require_relative "session_inbox"
|
|
10
|
+
require_relative "steer"
|
|
5
11
|
require_relative "recap_store"
|
|
6
12
|
|
|
7
13
|
module Samagotchi
|
|
@@ -16,9 +22,16 @@ module Samagotchi
|
|
|
16
22
|
LAST_PREFIX = "last:"
|
|
17
23
|
# Who the carry note is from (ContextNote's label: "note from session chain").
|
|
18
24
|
NOTE_SOURCE = "session chain"
|
|
19
|
-
# The most of the previous link's recap
|
|
20
|
-
#
|
|
25
|
+
# The most of the previous link's recap a note takes; a recap is a few
|
|
26
|
+
# sentences. Characters, not bytes: #note_text holds the carry note to
|
|
27
|
+
# SessionInbox::NOTE_MAX_BYTES itself.
|
|
21
28
|
RECAP_CHARS = 8000
|
|
29
|
+
# The most of the previous link's last answer the carry note takes. A
|
|
30
|
+
# wrap-up closes on what comes next, so a longer one keeps its end.
|
|
31
|
+
LAST_ANSWER_CHARS = 4000
|
|
32
|
+
LAST_ANSWER_HEAD = "\n\nIts last answer:\n"
|
|
33
|
+
# The "…" that marks a cut, in bytes.
|
|
34
|
+
CUT_MARK_BYTES = 3
|
|
22
35
|
|
|
23
36
|
module_function
|
|
24
37
|
|
|
@@ -63,14 +76,79 @@ module Samagotchi
|
|
|
63
76
|
end
|
|
64
77
|
|
|
65
78
|
# The context note a new link starts with: which session it continues,
|
|
66
|
-
# from when,
|
|
67
|
-
#
|
|
68
|
-
#
|
|
79
|
+
# from when, that session's recap with its time, when it has one, and
|
|
80
|
+
# its last answer (#last_answer; session.carry_last_answer). No other transcript. Never over
|
|
81
|
+
# SessionInbox::NOTE_MAX_BYTES, whatever the session holds: the inbox
|
|
82
|
+
# rejects a longer note, and a continue writes it after the archive. The
|
|
83
|
+
# answer gets what the rest leaves.
|
|
84
|
+
# @param previous [Session] the answer is read fresh from its file
|
|
69
85
|
# @param recap [Hash, nil] RecapStore.read's
|
|
70
86
|
# @return [String]
|
|
71
|
-
def note_text(previous, recap:)
|
|
87
|
+
def note_text(previous, recap:, state_dir:)
|
|
72
88
|
head = "This session continues #{previous.id[0, 8]} (#{clock(previous.created_at) || previous.created_at}), the previous link of its chain."
|
|
73
|
-
"#{head}#{recap_text(recap)}"
|
|
89
|
+
text = keep_start("#{head}#{recap_text(recap)}", SessionInbox::NOTE_MAX_BYTES)
|
|
90
|
+
room = SessionInbox::NOTE_MAX_BYTES - text.bytesize - LAST_ANSWER_HEAD.bytesize
|
|
91
|
+
answer = carried_answer(previous, room: room, state_dir: state_dir)
|
|
92
|
+
answer ? "#{text}#{LAST_ANSWER_HEAD}#{answer}" : text
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The last answer as the carry note takes it, in at most +room+ bytes;
|
|
96
|
+
# nil with session.carry_last_answer off, when there is none, when a
|
|
97
|
+
# review link has it (a continue copies review: but not the grants, so
|
|
98
|
+
# the next link's participants could differ) or when the session can't
|
|
99
|
+
# be read: this runs inside a continue's rollback, and a note without it
|
|
100
|
+
# is still a good note.
|
|
101
|
+
# @return [String, nil]
|
|
102
|
+
def carried_answer(previous, room:, state_dir:)
|
|
103
|
+
return nil if previous.review? || !Config.get("session.carry_last_answer")
|
|
104
|
+
|
|
105
|
+
text = last_answer(Session.load(previous.id, state_dir: state_dir).messages)
|
|
106
|
+
text && keep_end(text, room)
|
|
107
|
+
rescue StandardError => e
|
|
108
|
+
Log.warn(:worker, "continue_last_answer_failed", sid: previous.id, error: e.class.name, msg: e.message)
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The session's last word: the text of AnswerTail's newest bubble, with
|
|
113
|
+
# the wire-format markup stripped, unless that bubble is a step of a
|
|
114
|
+
# turn (it asked for tools) or old text a later turn never replaced
|
|
115
|
+
# (one that failed or was cut before it answered). A cut reply keeps
|
|
116
|
+
# its "[interrupted]": that is what happened.
|
|
117
|
+
# @param messages [Array<Hash>]
|
|
118
|
+
# @return [String, nil]
|
|
119
|
+
def last_answer(messages)
|
|
120
|
+
found = AnswerTail.find(messages)
|
|
121
|
+
return nil unless found && found[:content].is_a?(String) && Array(found[:tool_calls]).empty?
|
|
122
|
+
|
|
123
|
+
at = messages.rindex { |m| m.equal?(found) }
|
|
124
|
+
return nil if messages[(at + 1)..].any? { |m| m[:role].to_s == "tool_response" || later_turn?(m) }
|
|
125
|
+
|
|
126
|
+
OutputFormatter.strip_markup(found[:content].scrub)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# A turn's opening, not a `!shell` line: a command the user ran after
|
|
130
|
+
# the answer leaves it the last word.
|
|
131
|
+
def later_turn?(message)
|
|
132
|
+
Steer.turn_prompt?(message) && !shell_line?(message)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# At most +max+ bytes of +text+'s start, cut on a character.
|
|
136
|
+
def keep_start(text, max)
|
|
137
|
+
return text if text.bytesize <= max
|
|
138
|
+
|
|
139
|
+
"#{text.byteslice(0, max - CUT_MARK_BYTES).scrub("")}…"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# +text+'s end: at most LAST_ANSWER_CHARS characters and +max+ bytes,
|
|
143
|
+
# with the "…" of a cut; nil when no character fits.
|
|
144
|
+
def keep_end(text, max)
|
|
145
|
+
return nil if max <= CUT_MARK_BYTES
|
|
146
|
+
|
|
147
|
+
text = "…#{text[(1 - LAST_ANSWER_CHARS)..]}" if text.length > LAST_ANSWER_CHARS
|
|
148
|
+
return text if text.bytesize <= max
|
|
149
|
+
|
|
150
|
+
kept = text.byteslice(-(max - CUT_MARK_BYTES), max - CUT_MARK_BYTES).scrub("")
|
|
151
|
+
kept.empty? ? nil : "…#{kept}"
|
|
74
152
|
end
|
|
75
153
|
|
|
76
154
|
# +recap+ as a note's tail: " Its recap, as of <time>:\n<text>" (cut
|
|
@@ -103,13 +181,18 @@ module Samagotchi
|
|
|
103
181
|
|
|
104
182
|
text = msg[:content]
|
|
105
183
|
next unless text.is_a?(String) && !text.strip.empty?
|
|
106
|
-
next if
|
|
184
|
+
next if shell_line?(msg)
|
|
107
185
|
|
|
108
186
|
return text.strip
|
|
109
187
|
end
|
|
110
188
|
nil
|
|
111
189
|
end
|
|
112
190
|
|
|
191
|
+
# A stored `!shell` line: "!(cmd)\n<output>", no turn_id.
|
|
192
|
+
def shell_line?(message)
|
|
193
|
+
message[:turn_id].nil? && message[:content].is_a?(String) && message[:content].start_with?("!(")
|
|
194
|
+
end
|
|
195
|
+
|
|
113
196
|
def clock(iso)
|
|
114
197
|
iso && Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
|
|
115
198
|
rescue ArgumentError
|
|
@@ -534,7 +534,7 @@ module Samagotchi
|
|
|
534
534
|
rules.each do |rule|
|
|
535
535
|
summary = SystemOne::Record.new(entries, rule: rule).summary
|
|
536
536
|
counts = summary.reject { |key, _| key == :rule }.map { |key, value| "#{key}=#{value}" }.join(", ")
|
|
537
|
-
lines << " #{rule}: #{counts}"
|
|
537
|
+
lines << " #{rule}#{" (per repo)" if SystemOne::Record.per_repo?(rule)}: #{counts}"
|
|
538
538
|
end
|
|
539
539
|
end
|
|
540
540
|
end
|
|
@@ -69,7 +69,8 @@ module Samagotchi
|
|
|
69
69
|
# +id_or_ref+ (Session#continues) in its folder, on its model (the name
|
|
70
70
|
# it was typed as, so an alias holds) and with its own setup (SessionSetup),
|
|
71
71
|
# starting with a context note from chi: the link and the previous
|
|
72
|
-
# link's recap (SessionChain.note_text)
|
|
72
|
+
# link's recap and last answer (SessionChain.note_text), read after the
|
|
73
|
+
# recap wait, so a turn that finished in it counts. +model+, when given, is the
|
|
73
74
|
# new link's model instead; an inherited one whose host can't run it any
|
|
74
75
|
# more (gone, disabled, an alias naming another host) falls back to the
|
|
75
76
|
# default with a warning (#model_for). The previous link is archived
|
|
@@ -212,7 +213,7 @@ module Samagotchi
|
|
|
212
213
|
link = SessionManager.spawn_session(
|
|
213
214
|
prompt: start, preview: preview, title: previous.title, working_directory: previous.working_directory,
|
|
214
215
|
model_name: choice.model, setup: SessionSetup.of(previous), review: previous.review,
|
|
215
|
-
continues: previous.id, note: SessionChain.note_text(previous, recap: recap),
|
|
216
|
+
continues: previous.id, note: SessionChain.note_text(previous, recap: recap, state_dir: state_dir),
|
|
216
217
|
note_source: SessionChain::NOTE_SOURCE, state_dir: state_dir, before_spawn: before_spawn
|
|
217
218
|
)
|
|
218
219
|
link.moved_children = move
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../config"
|
|
4
|
+
require_relative "../model_match"
|
|
5
|
+
|
|
3
6
|
module Samagotchi
|
|
4
7
|
module SystemOne
|
|
5
8
|
# One rule's record: what System 1 has judged for it and what the user
|
|
@@ -7,7 +10,9 @@ module Samagotchi
|
|
|
7
10
|
# joined with their last label), filtered to one rule. The gate before
|
|
8
11
|
# System 1 answers for the user: enough clean allows at the threshold,
|
|
9
12
|
# and no false allow among the rule's last 20 labelled asks
|
|
10
|
-
# (docs/guardrails.md, "System 1").
|
|
13
|
+
# (docs/guardrails.md, "System 1"). A rule in
|
|
14
|
+
# guardrails.system_one.repo_rules goes by repository: what the user allowed
|
|
15
|
+
# in one says nothing about another, so its record is built per repo.
|
|
11
16
|
#
|
|
12
17
|
# An ask with no label, no probability, or a `none` label counts for
|
|
13
18
|
# neither side: only a judgment the user actually answered teaches
|
|
@@ -21,13 +26,22 @@ module Samagotchi
|
|
|
21
26
|
# How many of the rule's labelled asks the false-allow check looks at.
|
|
22
27
|
WINDOW = 20
|
|
23
28
|
|
|
29
|
+
# Whether +rule+'s record goes by repository
|
|
30
|
+
# (guardrails.system_one.repo_rules, read like .rules).
|
|
31
|
+
def self.per_repo?(rule)
|
|
32
|
+
ModelMatch.parse(Config.get("guardrails.system_one.repo_rules")).include?(rule.to_s)
|
|
33
|
+
end
|
|
34
|
+
|
|
24
35
|
attr_reader :rule, :entries
|
|
25
36
|
|
|
26
37
|
# @param entries [Array<Hash>] SystemOne::Log#entries, oldest first
|
|
27
38
|
# @param rule [String] the rule the record is for
|
|
28
|
-
|
|
39
|
+
# @param repo [String, nil] only this repository's asks (a judgment's
|
|
40
|
+
# repo: Guardrails::Targets#repo, its common git dir); nil: all of the
|
|
41
|
+
# rule's. A line with no repo is in no repository's record.
|
|
42
|
+
def initialize(entries, rule:, repo: nil)
|
|
29
43
|
@rule = rule.to_s
|
|
30
|
-
@entries = Array(entries).select { |entry| entry["rule"].to_s == @rule }
|
|
44
|
+
@entries = Array(entries).select { |entry| entry["rule"].to_s == @rule && (repo.nil? || entry["repo"] == repo) }
|
|
31
45
|
end
|
|
32
46
|
|
|
33
47
|
# @param threshold [Float] the probability an auto-allow needs
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -1018,7 +1018,7 @@ module Samagotchi
|
|
|
1018
1018
|
def handle_info(req)
|
|
1019
1019
|
return participant_info unless principal(req).owner?
|
|
1020
1020
|
|
|
1021
|
-
chi_state = @hub&.chi_state || { version: Samagotchi::VERSION, installed: nil, available: nil, update_blocked: nil, auto: "off", restart: "idle" }
|
|
1021
|
+
chi_state = @hub&.chi_state || { version: Samagotchi::VERSION, installed: nil, available: nil, update_blocked: nil, auto: "off", restart: "idle", auto_offers: nil }
|
|
1022
1022
|
json_response(200, { app: "chi-web", **chi_state, pid: Process.pid,
|
|
1023
1023
|
cwd: Dir.pwd, start_dir: start_dir, features: INFO_FEATURES, lan: @lan && @lan[:ip],
|
|
1024
1024
|
proxy: @public_url && { identity_header: @public_url.identity_header } })
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "rubygems/version"
|
|
4
4
|
require "time"
|
|
5
5
|
require_relative "../config"
|
|
6
|
+
require_relative "../installed_versions"
|
|
6
7
|
require_relative "../log"
|
|
7
8
|
require_relative "../version"
|
|
8
9
|
require_relative "auto_update_record"
|
|
@@ -37,6 +38,17 @@ module Samagotchi
|
|
|
37
38
|
# A preflight that fails leaves chi web as it is and says so.
|
|
38
39
|
# update.restart: never stops after the install.
|
|
39
40
|
#
|
|
41
|
+
# A newer chi installed some other way (a terminal's `chi update`, inside
|
|
42
|
+
# update.min_age_hours or not) is adopted: once it has settled and the
|
|
43
|
+
# gate is open, it gets a run from step 1 on. One run per version here
|
|
44
|
+
# too, so a restart that didn't take isn't tried again.
|
|
45
|
+
#
|
|
46
|
+
# The page offers no restart buttons for a run that will do them itself.
|
|
47
|
+
# It offers them again when this says so (SessionHub#auto_offer_restart):
|
|
48
|
+
# a run on record for the version ended with chi web still older, or
|
|
49
|
+
# something held it for HELD_FOR, a forgotten question or a long turn.
|
|
50
|
+
# The trying goes on behind that offer.
|
|
51
|
+
#
|
|
40
52
|
# The run's record (AutoUpdateRecord) lives in update_check.json
|
|
41
53
|
# (UpdateCheck#save_auto), so a chi web restart finds it. See
|
|
42
54
|
# docs/architecture.md.
|
|
@@ -56,6 +68,13 @@ module Samagotchi
|
|
|
56
68
|
OPEN_PHASES = %w[installed workers web].freeze
|
|
57
69
|
# The phase after "chi updated" while workers are left.
|
|
58
70
|
RETRYING = "retrying"
|
|
71
|
+
# A newer chi seen installed is adopted only this long after: `chi update`
|
|
72
|
+
# installs the gem first and merges the bundles after, with no lock or
|
|
73
|
+
# marker to look at. In practice the next POLL.
|
|
74
|
+
SETTLE = 60
|
|
75
|
+
# How long a newer installed chi may wait for its restart before the page
|
|
76
|
+
# offers the buttons again.
|
|
77
|
+
HELD_FOR = 3600
|
|
59
78
|
# How long stop waits for the thread: long enough for a preflight
|
|
60
79
|
# (Server::PREFLIGHT_TIMEOUT, 20 s; a healthy one takes about a
|
|
61
80
|
# second) to end on its own, so a stop never abandons one mid-way.
|
|
@@ -84,6 +103,7 @@ module Samagotchi
|
|
|
84
103
|
@wakeup = Queue.new
|
|
85
104
|
@thread = nil
|
|
86
105
|
@stopped = false
|
|
106
|
+
@seen = nil
|
|
87
107
|
end
|
|
88
108
|
|
|
89
109
|
def start
|
|
@@ -108,17 +128,17 @@ module Samagotchi
|
|
|
108
128
|
def step
|
|
109
129
|
return unless @auto == "install"
|
|
110
130
|
|
|
131
|
+
note_installed
|
|
111
132
|
record = @check.auto_state
|
|
112
133
|
return carry_on(record) if record && OPEN_PHASES.include?(record.phase)
|
|
113
134
|
|
|
114
135
|
retry_left(record) if record&.phase == RETRYING
|
|
136
|
+
return if @ops.blocked_reason || @ops.running? || busy_turn?
|
|
115
137
|
|
|
116
138
|
version = @check.available
|
|
117
|
-
return
|
|
118
|
-
return if @check.auto_failed?(version) || tried?(version)
|
|
119
|
-
return if @ops.blocked_reason || @ops.running? || busy_turn?
|
|
139
|
+
return install(version) if version && !@check.auto_failed?(version) && !tried?(version)
|
|
120
140
|
|
|
121
|
-
|
|
141
|
+
adopt_installed
|
|
122
142
|
end
|
|
123
143
|
|
|
124
144
|
private
|
|
@@ -158,6 +178,37 @@ module Samagotchi
|
|
|
158
178
|
end
|
|
159
179
|
end
|
|
160
180
|
|
|
181
|
+
# What is installed, ahead of every gate: since when a newer chi is, and
|
|
182
|
+
# whether the page should offer the restarts for it. A chi web restart
|
|
183
|
+
# starts the clock over, and finds a closed run's offer again here.
|
|
184
|
+
def note_installed
|
|
185
|
+
installed = @hub.refresh_installed
|
|
186
|
+
return @seen = nil unless InstalledVersions.newer?(installed)
|
|
187
|
+
|
|
188
|
+
now = @clock.call
|
|
189
|
+
@seen = [installed, now] unless @seen&.first == installed
|
|
190
|
+
@hub.auto_offer_restart(installed) if closed_run?(installed) || now - @seen.last >= HELD_FOR
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A run on record for +version+ that is over, with chi web still older.
|
|
194
|
+
def closed_run?(version)
|
|
195
|
+
record = @check.auto_state
|
|
196
|
+
!record.nil? && record.version == version && !OPEN_PHASES.include?(record.phase)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# A newer chi this didn't install, settled: a run of its own from here.
|
|
200
|
+
# The terminal's table already told what `chi update` kept.
|
|
201
|
+
def adopt_installed
|
|
202
|
+
return unless @restart == "idle" && @seen
|
|
203
|
+
|
|
204
|
+
installed, seen_at = @seen
|
|
205
|
+
now = @clock.call
|
|
206
|
+
return if tried?(installed) || now - seen_at < SETTLE
|
|
207
|
+
|
|
208
|
+
Log.info(:web, "auto_update_adopted", version: installed, from: VERSION)
|
|
209
|
+
carry_on(save(AutoUpdateRecord.new(version: installed, from: VERSION, at: now.iso8601, phase: "installed")))
|
|
210
|
+
end
|
|
211
|
+
|
|
161
212
|
def install(version)
|
|
162
213
|
Log.info(:web, "auto_update_start", version: version, from: VERSION)
|
|
163
214
|
result = @ops.install(version, wait: true, auto: true)
|
|
@@ -187,7 +238,9 @@ module Samagotchi
|
|
|
187
238
|
# The next step of a run on record.
|
|
188
239
|
def carry_on(record)
|
|
189
240
|
case record.phase
|
|
190
|
-
when "installed", "workers"
|
|
241
|
+
when "installed", "workers"
|
|
242
|
+
# Restarted by hand on the way (the page's offer): nothing left to exec.
|
|
243
|
+
InstalledVersions.newer?(record.version) ? workers_then_web(record) : finish(record)
|
|
191
244
|
when "web" then finish(record)
|
|
192
245
|
end
|
|
193
246
|
end
|
|
@@ -260,6 +313,7 @@ module Samagotchi
|
|
|
260
313
|
def restart_failed(record, detail, event)
|
|
261
314
|
Log.warn(:web, event, version: record.version, detail: detail)
|
|
262
315
|
save(record.with(phase: "failed", detail: detail, pushed: true))
|
|
316
|
+
@hub.auto_offer_restart(record.version)
|
|
263
317
|
@hub.publish_notice(kind: "restart_failed", version: record.version, detail: detail)
|
|
264
318
|
end
|
|
265
319
|
|
|
@@ -307,6 +361,7 @@ module Samagotchi
|
|
|
307
361
|
Log.warn(:web, "auto_update_failed_install", version: record.version, detail: detail)
|
|
308
362
|
gem_installed = rows.any? { |candidate| candidate["component"] == "chi (gem)" && !candidate["failed"] }
|
|
309
363
|
@check.save_auto(record.with(phase: "failed", detail: detail, pushed: true), failed: record.version)
|
|
364
|
+
@hub.auto_offer_restart(record.version)
|
|
310
365
|
@hub.publish_notice(kind: "failed", version: record.version, detail: detail.empty? ? nil : detail,
|
|
311
366
|
installed: gem_installed)
|
|
312
367
|
end
|
|
@@ -95,7 +95,7 @@ import { waitingOn } from "./notify.js";
|
|
|
95
95
|
import { applySessionEvent, sortedByUpdated } from "./sessions_list.js";
|
|
96
96
|
import { createNotifications } from "./notifications.js";
|
|
97
97
|
import { renameShown, tabTitle, topBarTitle } from "./session_title.js";
|
|
98
|
-
import { mergeVersionState, restartConfirmText, versionNotice } from "./update_notice.js";
|
|
98
|
+
import { mergeVersionState, restartConfirmText, updateFrameToast, versionNotice } from "./update_notice.js";
|
|
99
99
|
import {
|
|
100
100
|
appendAboveLiveTiming, mergeTiming, normalizeTiming,
|
|
101
101
|
} from "./timing.js";
|
|
@@ -585,7 +585,7 @@ function noticeVersions(data) {
|
|
|
585
585
|
const notice = versionNotice({ loaded: document.body.dataset.version, served: versions.served,
|
|
586
586
|
installed: versions.installed, available: versions.available,
|
|
587
587
|
updateBlocked: versions.updateBlocked, auto: versions.auto, restart: versions.restart,
|
|
588
|
-
|
|
588
|
+
autoOffers: versions.autoOffers, shown: versionNoticesShown });
|
|
589
589
|
store.touch("infoBar"); // the worker badge compares with the newest installed
|
|
590
590
|
if (!notice) return;
|
|
591
591
|
versionNoticesShown.add(notice.key);
|
|
@@ -614,20 +614,9 @@ function noticeVersions(data) {
|
|
|
614
614
|
function updateFrame(data, { snapshot = false } = {}) {
|
|
615
615
|
const wasOpen = updateSheet.isOpen();
|
|
616
616
|
if (!updateSheet.receive(data)) return;
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
if (data.operation === "install") {
|
|
621
|
-
noticeVersions({ ...(data.state === "done" && data.version ? { installed: data.version } : {}), chi_update: data });
|
|
622
|
-
}
|
|
623
|
-
const label = `${data.auto ? "Automatic " : ""}${data.operation === "install" ? "chi update" : "worker restart"}`
|
|
624
|
-
.replace(/^./, (c) => c.toUpperCase());
|
|
625
|
-
if (!wasOpen && data.state === "running") {
|
|
626
|
-
showToast(`${label} in progress`, { label: "View", run: () => updateSheet.view() }, { sticky: true });
|
|
627
|
-
} else if (!snapshot && !wasOpen && ["done", "failed"].includes(data.state)
|
|
628
|
-
&& !(data.operation === "install" && data.state === "done")) {
|
|
629
|
-
showToast(`${label} ${data.state}`, { label: "View", run: () => updateSheet.view() }, { sticky: true });
|
|
630
|
-
}
|
|
617
|
+
if (data.operation === "install" && data.state === "done" && data.version) noticeVersions({ installed: data.version });
|
|
618
|
+
const text = updateFrameToast(data, { wasOpen, snapshot });
|
|
619
|
+
if (text) showToast(text, { label: "View", run: () => updateSheet.view() }, { sticky: true });
|
|
631
620
|
}
|
|
632
621
|
|
|
633
622
|
function copyUpdateCommand(command) {
|