samagotchi 0.133.0 → 0.135.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 +116 -1
- data/bin/chi +2 -3
- data/docs/architecture.md +6 -3
- data/docs/cli.md +50 -34
- data/docs/configuration.md +9 -6
- data/docs/guardrails.md +146 -13
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +6 -3
- data/docs/internals/participants.md +7 -6
- data/docs/internals/tool-guardrails.md +2 -0
- data/docs/internals/web-frontend.md +262 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +5 -2
- data/docs/testing.md +34 -4
- data/lib/samagotchi/bridge.rb +11 -6
- data/lib/samagotchi/bridge_client.rb +8 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/chi_home.rb +12 -2
- data/lib/samagotchi/config.rb +0 -2
- data/lib/samagotchi/desktop/macos.rb +19 -4
- data/lib/samagotchi/engine.rb +82 -12
- data/lib/samagotchi/guardrail_wiring.rb +8 -6
- data/lib/samagotchi/guardrails/approval.rb +41 -5
- data/lib/samagotchi/guardrails/implicit_writers.rb +158 -0
- data/lib/samagotchi/guardrails/pair_boundary.rb +11 -5
- data/lib/samagotchi/guardrails/pair_command.rb +828 -86
- data/lib/samagotchi/guardrails/parent_approvals.rb +13 -16
- data/lib/samagotchi/guardrails/read_only_shell.rb +58 -4
- data/lib/samagotchi/guardrails/shared_git.rb +1 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +7 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +45 -0
- data/lib/samagotchi/guardrails/shell_paths.rb +3 -2
- data/lib/samagotchi/guardrails/targets.rb +8 -3
- data/lib/samagotchi/guardrails_command.rb +13 -10
- data/lib/samagotchi/home_command.rb +22 -6
- data/lib/samagotchi/home_doctor.rb +36 -3
- data/lib/samagotchi/home_notice.rb +8 -1
- data/lib/samagotchi/hooks/loader.rb +5 -0
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/log_line.rb +12 -0
- data/lib/samagotchi/message_parts.rb +1 -1
- data/lib/samagotchi/project_env.rb +3 -11
- data/lib/samagotchi/revise_anchor.rb +33 -0
- data/lib/samagotchi/self_report.rb +3 -1
- data/lib/samagotchi/session_inbox.rb +20 -12
- data/lib/samagotchi/session_manager.rb +13 -11
- data/lib/samagotchi/system_one/approval_question.rb +21 -16
- data/lib/samagotchi/system_one/self_protect.rb +70 -16
- data/lib/samagotchi/testing/waiting.rb +13 -0
- data/lib/samagotchi/toolkit_binding.rb +16 -0
- data/lib/samagotchi/tools/delegate_relay.rb +6 -5
- data/lib/samagotchi/tools/delegate_wait.rb +5 -5
- data/lib/samagotchi/tools/execute.rb +6 -6
- data/lib/samagotchi/tools/output_guardrails.rb +9 -2
- data/lib/samagotchi/tools/read.rb +5 -5
- data/lib/samagotchi/turn_anchors.rb +49 -0
- data/lib/samagotchi/update_command.rb +7 -3
- data/lib/samagotchi/update_hint.rb +6 -1
- data/lib/samagotchi/verify_anchor.rb +5 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +2 -14
- data/lib/samagotchi/web/message_display.rb +17 -1
- data/lib/samagotchi/web/participant_access.rb +2 -2
- data/lib/samagotchi/web/participant_view.rb +8 -0
- data/lib/samagotchi/web/public/activity.js +1 -1
- data/lib/samagotchi/web/public/annotations.js +1 -1
- data/lib/samagotchi/web/public/answer_fold.js +262 -0
- data/lib/samagotchi/web/public/answer_refs.js +2 -0
- data/lib/samagotchi/web/public/app.js +462 -338
- data/lib/samagotchi/web/public/bubbles.js +3 -3
- data/lib/samagotchi/web/public/cards_place.js +9 -4
- data/lib/samagotchi/web/public/chip_popover.js +73 -11
- data/lib/samagotchi/web/public/claim_link.js +4 -2
- data/lib/samagotchi/web/public/composer.css +11 -1
- data/lib/samagotchi/web/public/context_chips.js +9 -6
- data/lib/samagotchi/web/public/data.js +4 -2
- data/lib/samagotchi/web/public/dock_reserve.js +13 -15
- data/lib/samagotchi/web/public/feature_gates.js +33 -0
- data/lib/samagotchi/web/public/history_reveal.js +27 -0
- data/lib/samagotchi/web/public/index.html +82 -37
- data/lib/samagotchi/web/public/latest.js +46 -0
- data/lib/samagotchi/web/public/lists_view.js +15 -13
- data/lib/samagotchi/web/public/live_timing.js +2 -0
- data/lib/samagotchi/web/public/llm_context_chip.js +5 -13
- data/lib/samagotchi/web/public/markdown_place.js +1 -1
- data/lib/samagotchi/web/public/method_api.js +14 -0
- data/lib/samagotchi/web/public/notifications.js +4 -11
- data/lib/samagotchi/web/public/participant.js +11 -6
- data/lib/samagotchi/web/public/participant_names.js +1 -1
- data/lib/samagotchi/web/public/participants_panel.js +4 -2
- data/lib/samagotchi/web/public/project_picker.js +18 -22
- data/lib/samagotchi/web/public/question_cards.js +2 -2
- data/lib/samagotchi/web/public/refs.js +6 -7
- data/lib/samagotchi/web/public/restore.js +2 -0
- data/lib/samagotchi/web/public/review_start.js +1 -1
- data/lib/samagotchi/web/public/revise_stack.js +372 -0
- data/lib/samagotchi/web/public/scope.js +12 -20
- data/lib/samagotchi/web/public/send_turn.js +29 -16
- data/lib/samagotchi/web/public/session_actions.js +15 -14
- data/lib/samagotchi/web/public/session_fields.js +152 -0
- data/lib/samagotchi/web/public/session_payload.js +35 -0
- data/lib/samagotchi/web/public/session_state.js +2 -0
- data/lib/samagotchi/web/public/session_store.js +191 -0
- data/lib/samagotchi/web/public/stage_model.js +4 -4
- data/lib/samagotchi/web/public/stage_view.js +11 -16
- data/lib/samagotchi/web/public/start_page.js +4 -4
- data/lib/samagotchi/web/public/start_settings.js +29 -18
- data/lib/samagotchi/web/public/start_toolkit.js +5 -4
- data/lib/samagotchi/web/public/stream_handlers.js +88 -90
- data/lib/samagotchi/web/public/task_stop.js +1 -1
- data/lib/samagotchi/web/public/timing.js +7 -3
- data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +46 -101
- data/lib/samagotchi/web/public/turn_events.js +2 -2
- data/lib/samagotchi/web/public/turn_html.js +17 -9
- data/lib/samagotchi/web/public/turn_model.js +6 -6
- data/lib/samagotchi/web/public/turn_range.js +32 -0
- data/lib/samagotchi/web/public/verdict_card.js +5 -13
- data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
- data/lib/samagotchi/web/public/verify_target.js +7 -10
- data/lib/samagotchi/web/public/working_set_view.js +3 -1
- data/lib/samagotchi/web/server.rb +4 -4
- data/lib/samagotchi/web/session_control.rb +2 -2
- data/lib/samagotchi/worker.rb +9 -9
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +16 -3
- data/lib/samagotchi/web/public/hold.js +0 -78
|
@@ -42,7 +42,7 @@ module Samagotchi
|
|
|
42
42
|
MEMORY_REMOVE_RULE = "memory-remove"
|
|
43
43
|
# The guardrails bundle's shell-touches-chi, for a word it can't
|
|
44
44
|
# resolve to a path (Guardrails::ShellPaths): CHI_TEXT and .git/hooks.
|
|
45
|
-
CHI_SHELL_TEXT = Regexp.union(CHI_TEXT, %r{\.git/hooks})
|
|
45
|
+
CHI_SHELL_TEXT = Regexp.union(CHI_TEXT, %r{\.git/(?:modules/\S+/)?hooks})
|
|
46
46
|
|
|
47
47
|
module_function
|
|
48
48
|
|
|
@@ -63,13 +63,15 @@ module Samagotchi
|
|
|
63
63
|
|
|
64
64
|
# #protected? on an approval's facts (Approval.payload's approval:),
|
|
65
65
|
# at ask time.
|
|
66
|
+
# @param facts [ApprovalFacts, Hash, nil] as built, or as saved
|
|
66
67
|
def protected_facts?(facts)
|
|
67
|
-
|
|
68
|
-
return
|
|
69
|
-
return true if
|
|
68
|
+
facts = ApprovalFacts.from_h(facts)
|
|
69
|
+
return false if facts.nil?
|
|
70
|
+
return true if PROTECTED_RULES.include?(facts.rule.to_s)
|
|
71
|
+
return true if facts.source.to_s == ProtectedPaths::SOURCE
|
|
70
72
|
# A memory's file sits in the config dir, but removing one is the
|
|
71
73
|
# memory-remove rule's ask, which a parent may answer.
|
|
72
|
-
return false if
|
|
74
|
+
return false if facts.rule.to_s == MEMORY_REMOVE_RULE
|
|
73
75
|
|
|
74
76
|
chi_files?(facts)
|
|
75
77
|
end
|
|
@@ -77,15 +79,17 @@ module Samagotchi
|
|
|
77
79
|
# Whether an approval's paths or command name chi's own dirs (#chi_dirs)
|
|
78
80
|
# or CHI_TEXT, whatever its rule: the part of #protected_facts? that
|
|
79
81
|
# System 1's SystemOne::SelfProtect shares.
|
|
82
|
+
# @param facts [ApprovalFacts, Hash, nil] as built, or as saved
|
|
80
83
|
def chi_files?(facts)
|
|
81
|
-
|
|
84
|
+
facts = ApprovalFacts.from_h(facts)
|
|
85
|
+
return false if facts.nil?
|
|
82
86
|
|
|
83
87
|
dirs = chi_dirs
|
|
84
|
-
paths = Array(
|
|
88
|
+
paths = Array(facts.paths).map(&:to_s).reject(&:empty?)
|
|
85
89
|
reals = paths.flat_map { |path| PathResolve.reals(path) }
|
|
86
90
|
return true if reals.any? { |real| dirs.any? { |dir| PathResolve.within?(real, dir) } }
|
|
87
91
|
|
|
88
|
-
command =
|
|
92
|
+
command = facts.command.to_s
|
|
89
93
|
return false if command.empty?
|
|
90
94
|
|
|
91
95
|
command.match?(CHI_TEXT) || dirs.any? { |dir| command.include?(dir) || command.include?(PathResolve.real(dir)) }
|
|
@@ -103,8 +107,7 @@ module Samagotchi
|
|
|
103
107
|
# The offered scopes, in option order, or nil when they are missing,
|
|
104
108
|
# empty, not strings, or leave no option after them for Deny.
|
|
105
109
|
def scopes(pending)
|
|
106
|
-
|
|
107
|
-
scopes = facts.is_a?(Hash) ? fetch(facts, :scopes) : nil
|
|
110
|
+
scopes = ApprovalFacts.from_h(pending.approval)&.scopes
|
|
108
111
|
return nil unless scopes.is_a?(Array) && !scopes.empty? && scopes.all?(String)
|
|
109
112
|
return nil unless scopes.size < options(pending).size
|
|
110
113
|
|
|
@@ -112,12 +115,6 @@ module Samagotchi
|
|
|
112
115
|
end
|
|
113
116
|
|
|
114
117
|
def options(pending) = Array(pending.options)
|
|
115
|
-
|
|
116
|
-
def fetch(hash, key)
|
|
117
|
-
return nil unless hash.is_a?(Hash)
|
|
118
|
-
|
|
119
|
-
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
120
|
-
end
|
|
121
118
|
end
|
|
122
119
|
end
|
|
123
120
|
end
|
|
@@ -17,7 +17,9 @@ module Samagotchi
|
|
|
17
17
|
# sh -c, eval, timeout, …), a background &, a $ expansion other than
|
|
18
18
|
# $HOME / the XDG dirs / $TMPDIR / $PWD at a word's start, a brace
|
|
19
19
|
# expansion, a < or << redirection, a path to a verb (/bin/ls), and
|
|
20
|
-
# less/more/vim (they run commands) are all not read-only.
|
|
20
|
+
# less/more/vim (they run commands) are all not read-only. test and
|
|
21
|
+
# [ … ] read only as one file or string check (.test_expr): `[[` and
|
|
22
|
+
# anything else they can do are not.
|
|
21
23
|
#
|
|
22
24
|
# .read_only_but_writes? (the child boundary's) also lets through the
|
|
23
25
|
# writes it can name: a > or >> target, cp's destination and tee's
|
|
@@ -98,12 +100,44 @@ module Samagotchi
|
|
|
98
100
|
text = command.to_s
|
|
99
101
|
return false if text.strip.empty?
|
|
100
102
|
|
|
101
|
-
|
|
102
|
-
!commands.empty? && commands.all? { |words| simple_read_only?(words) }
|
|
103
|
+
tokens_read_only?(ShellLex.lex(text))
|
|
103
104
|
rescue ArgumentError, EncodingError
|
|
104
105
|
false
|
|
105
106
|
end
|
|
106
107
|
|
|
108
|
+
def tokens_read_only?(tokens)
|
|
109
|
+
commands = split(tokens) or return false
|
|
110
|
+
!commands.empty? && commands.all? { |words| simple_read_only?(words) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Operators that end a part of a list (.read_only_parts) outside ( … ).
|
|
114
|
+
PART_ENDS = ["&&", "||", ";", "\n"].freeze
|
|
115
|
+
|
|
116
|
+
# Per simple command of +tokens+ (ShellLex.lex's; the word lists of
|
|
117
|
+
# ShellLex.simple_commands, in order), whether the part of the list it
|
|
118
|
+
# is in only reads: the list split at ;, &&, || and newlines outside
|
|
119
|
+
# ( … ). A pipe or a group keeps its commands in one part, so
|
|
120
|
+
# `find . -name x | xargs rm` reads as one part that writes.
|
|
121
|
+
# @return [Array<Boolean>]
|
|
122
|
+
def read_only_parts(tokens)
|
|
123
|
+
parts = [[]]
|
|
124
|
+
depth = 0
|
|
125
|
+
tokens.each do |token|
|
|
126
|
+
kind, value = token
|
|
127
|
+
depth += 1 if kind == :op && value == "("
|
|
128
|
+
depth -= 1 if kind == :op && value == ")" && depth.positive?
|
|
129
|
+
next parts << [] if kind == :op && depth.zero? && PART_ENDS.include?(value)
|
|
130
|
+
|
|
131
|
+
parts.last << token
|
|
132
|
+
end
|
|
133
|
+
parts.flat_map do |part|
|
|
134
|
+
read_only = tokens_read_only?(part)
|
|
135
|
+
ShellLex.simple_commands(part).grep(Array).map { read_only }
|
|
136
|
+
end
|
|
137
|
+
rescue ArgumentError, EncodingError
|
|
138
|
+
ShellLex.simple_commands(tokens).grep(Array).map { false }
|
|
139
|
+
end
|
|
140
|
+
|
|
107
141
|
# Whether the command only reads but for writes whose resolved paths
|
|
108
142
|
# the block accepts: > and >> targets, cp's destination (and the
|
|
109
143
|
# files it makes in it) and tee's files. Paths resolve against +cwd+
|
|
@@ -199,6 +233,7 @@ module Samagotchi
|
|
|
199
233
|
|
|
200
234
|
writes&.walk&.step(args)
|
|
201
235
|
verb = args.shift
|
|
236
|
+
return args.last == "]" && test_expr(args[0...-1]) if verb == "["
|
|
202
237
|
return false unless verb&.match?(/\A[a-z][\w.-]*\z/)
|
|
203
238
|
return true if PLAIN.include?(verb)
|
|
204
239
|
return WRITE_CHECKS[verb].call(args, writes) if writes && WRITE_CHECKS[verb]
|
|
@@ -244,6 +279,25 @@ module Samagotchi
|
|
|
244
279
|
|
|
245
280
|
def rg(args) = args.none? { |a| a.start_with?("--pre") }
|
|
246
281
|
|
|
282
|
+
# test's checks that only look: a file's type, mode or size, a string
|
|
283
|
+
# being empty or not.
|
|
284
|
+
TEST_UNARY = %w[-e -f -d -s -r -w -x -L -h -b -c -p -S -g -u -k -O -G -n -z].freeze
|
|
285
|
+
# Comparisons of two strings or integers.
|
|
286
|
+
TEST_BINARY = %w[= == != -eq -ne -lt -le -gt -ge].freeze
|
|
287
|
+
|
|
288
|
+
# test's (and [ … ]'s, without the ]) expression when it is one check:
|
|
289
|
+
# an optional !, then a unary TEST_UNARY with its operand or a
|
|
290
|
+
# TEST_BINARY between two. Any other shape (-a/-o, ( ), -v, one bare
|
|
291
|
+
# word, a missing operand) is not read-only.
|
|
292
|
+
def test_expr(args)
|
|
293
|
+
args = args.drop(1) if args.first == "!"
|
|
294
|
+
case args.size
|
|
295
|
+
when 2 then TEST_UNARY.include?(args[0])
|
|
296
|
+
when 3 then TEST_BINARY.include?(args[1])
|
|
297
|
+
else false
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
247
301
|
FIND_ACTIONS = %w[-delete -exec -execdir -ok -okdir -fprint -fprint0 -fprintf -fls].freeze
|
|
248
302
|
|
|
249
303
|
def find(args) = args.none? { |a| FIND_ACTIONS.any? { |action| a.start_with?(action) } }
|
|
@@ -458,7 +512,7 @@ module Samagotchi
|
|
|
458
512
|
CHECKS = {
|
|
459
513
|
"rg" => method(:rg), "find" => method(:find), "tree" => method(:tree), "file" => method(:file),
|
|
460
514
|
"sort" => method(:sort), "uniq" => method(:uniq), "sed" => method(:sed), "awk" => method(:awk),
|
|
461
|
-
"git" => method(:git), "sqlite3" => method(:sqlite3), "gh" => method(:gh)
|
|
515
|
+
"git" => method(:git), "sqlite3" => method(:sqlite3), "gh" => method(:gh), "test" => method(:test_expr)
|
|
462
516
|
}.freeze
|
|
463
517
|
|
|
464
518
|
# A sed script parsed far enough to tell that it only reads: its
|
|
@@ -52,7 +52,7 @@ module Samagotchi
|
|
|
52
52
|
def git_call(words)
|
|
53
53
|
words = words.drop_while { |w| w.match?(/\A[A-Za-z_]\w*=/) || ShellGitDirs::PREFIXES.include?(w) }
|
|
54
54
|
verb = words.first
|
|
55
|
-
return nil unless
|
|
55
|
+
return nil unless ShellLex.program?(verb, "git")
|
|
56
56
|
|
|
57
57
|
args = words.drop(1).grep_v(ShellLex::REDIRECT)
|
|
58
58
|
until args.empty?
|
|
@@ -28,7 +28,12 @@ module Samagotchi
|
|
|
28
28
|
STASH_READS = %w[list show].freeze
|
|
29
29
|
PREFIXES = %w[command builtin exec time nohup env ! { if then else elif do while until].freeze
|
|
30
30
|
# git global options that take the next word as their value.
|
|
31
|
-
VALUE_OPTIONS = %w[-C -c --git-dir --work-tree --namespace].freeze
|
|
31
|
+
VALUE_OPTIONS = %w[-C -c --git-dir --work-tree --namespace --attr-source --shallow-file].freeze
|
|
32
|
+
# The variables that hand git config of their own (another global or
|
|
33
|
+
# system file, settings as -c gives them) or a template (an init's
|
|
34
|
+
# hooks): what they set may run a command (Targets::SHELL_REDEFINING_ENV,
|
|
35
|
+
# PairCommand's refusal). GIT_CONFIG_NOSYSTEM only turns one off.
|
|
36
|
+
CONFIG_ENV = /\A(?:GIT_CONFIG(?:_GLOBAL|_SYSTEM|_PARAMETERS|_COUNT|_KEY_\d+|_VALUE_\d+)?|GIT_TEMPLATE_DIR)\z/
|
|
32
37
|
|
|
33
38
|
# @param command [String] the shell command
|
|
34
39
|
# @param cwd [String] where it starts (the call's cwd:, else the session's)
|
|
@@ -92,7 +97,7 @@ module Samagotchi
|
|
|
92
97
|
@pushed << @dirs
|
|
93
98
|
@dirs = args.first ? dirs_after(args.first) : [nil]
|
|
94
99
|
when "popd" then @dirs = @pushed.pop unless @pushed.empty?
|
|
95
|
-
else git(args, env) if
|
|
100
|
+
else git(args, env) if ShellLex.program?(verb, "git")
|
|
96
101
|
end
|
|
97
102
|
end
|
|
98
103
|
|
|
@@ -39,6 +39,28 @@ module Samagotchi
|
|
|
39
39
|
Lexer.new(text).tokens
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
# A program word's name as the guardrails compare it: its basename,
|
|
43
|
+
# case-folded (GIT, Git, /usr/bin/SH, ſh → git, sh), since a
|
|
44
|
+
# case-insensitive file system (macOS's default) runs `GIT` as git.
|
|
45
|
+
# Every check that tells a program by its name goes through this
|
|
46
|
+
# (PairCommand, ImplicitWriters, SelfProtect, ShellGitDirs, SharedGit).
|
|
47
|
+
# A word with a NUL (a SUBST) raises, as File.basename does.
|
|
48
|
+
def self.program_name(word)
|
|
49
|
+
name = File.basename(word.to_s)
|
|
50
|
+
name.valid_encoding? ? name.downcase(:fold) : name
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Whether +word+ runs the program +name+ (.program_name). A word with a
|
|
54
|
+
# NUL (a SUBST: "$(dirname $(which git))/git") by what follows its last
|
|
55
|
+
# `/`, folded the same way: no `/`, no name.
|
|
56
|
+
def self.program?(word, name)
|
|
57
|
+
word = word.to_s
|
|
58
|
+
return program_name(word) == name unless word.include?("\0")
|
|
59
|
+
|
|
60
|
+
last = word[%r{/([^/]*)\z}, 1] or return false
|
|
61
|
+
(last.valid_encoding? ? last.downcase(:fold) : last) == name
|
|
62
|
+
end
|
|
63
|
+
|
|
42
64
|
# Whether +text+ ends inside a quote, a $(…) or a heredoc: its tokens
|
|
43
65
|
# are a best guess.
|
|
44
66
|
def self.unterminated?(text)
|
|
@@ -63,6 +85,29 @@ module Samagotchi
|
|
|
63
85
|
commands.reject { |c| c.is_a?(Array) && c.empty? }
|
|
64
86
|
end
|
|
65
87
|
|
|
88
|
+
# For each word list .simple_commands gives (its :open / :close
|
|
89
|
+
# markers left out), whether it reads a pipe: a | or |& comes before
|
|
90
|
+
# it, a ( between them included (`x | (sh)`).
|
|
91
|
+
# @return [Array<Boolean>]
|
|
92
|
+
def self.piped_flags(tokens)
|
|
93
|
+
flags = []
|
|
94
|
+
words = 0
|
|
95
|
+
pending = false
|
|
96
|
+
piped = false
|
|
97
|
+
tokens.each do |kind, value|
|
|
98
|
+
if kind == :op
|
|
99
|
+
flags << piped if words.positive?
|
|
100
|
+
words = 0
|
|
101
|
+
pending = %w[| |&].include?(value) || (value == "(" && pending)
|
|
102
|
+
else
|
|
103
|
+
piped = pending if words.zero?
|
|
104
|
+
words += 1
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
flags << piped if words.positive?
|
|
108
|
+
flags
|
|
109
|
+
end
|
|
110
|
+
|
|
66
111
|
# Whether an ( or { group is still open at token +at+: a pipe after
|
|
67
112
|
# the group's ) or } takes what the group prints. The counters never go
|
|
68
113
|
# below 0 (a case pattern's "x)" closes nothing); ((…)) arithmetic and
|
|
@@ -11,7 +11,8 @@ module Samagotchi
|
|
|
11
11
|
# command is in by then (cd, pushd/popd and ( … ) followed as in
|
|
12
12
|
# ShellGitDirs), with ~, $HOME and the XDG dirs expanded and symlinks
|
|
13
13
|
# resolved. A word names a dir when it is in it. Any path with a
|
|
14
|
-
# .git/hooks part
|
|
14
|
+
# .git/hooks part (a submodule's .git/modules/<name>/hooks too) counts
|
|
15
|
+
# too, outside the tmp dirs.
|
|
15
16
|
#
|
|
16
17
|
# A word it can't resolve (another $VAR, $(…), a glob, a relative path
|
|
17
18
|
# after cd $X) or one that reads as a script (sh -c '…', ruby -e '…':
|
|
@@ -23,7 +24,7 @@ module Samagotchi
|
|
|
23
24
|
# A word that is a script rather than a path.
|
|
24
25
|
SCRIPT = /[\s;&|<>()'"`]/
|
|
25
26
|
GLOB = /[*?\[\]{}]/
|
|
26
|
-
HOOKS_PART = %r{(?:\A|/)\.git/hooks(?:/|\z)}
|
|
27
|
+
HOOKS_PART = %r{(?:\A|/)\.git/(?:modules/.+/)?hooks(?:/|\z)}
|
|
27
28
|
# $HOME, ${HOME} and the XDG dirs at a word's start (PathResolve's).
|
|
28
29
|
EXPANSION = PathResolve::EXPANSION
|
|
29
30
|
# Where XDG dirs default when unset.
|
|
@@ -46,7 +46,8 @@ module Samagotchi
|
|
|
46
46
|
# goes, under every shell chi may use or in the programs it runs: a
|
|
47
47
|
# startup file (BASH_ENV, ENV), an exported function (BASH_FUNC_…), a
|
|
48
48
|
# cd search path, word splitting, shell options, a library loaded into
|
|
49
|
-
# every program, git config or a diff program that runs
|
|
49
|
+
# every program, git config or a diff program that runs (and the rest
|
|
50
|
+
# of git's config variables: ShellGitDirs::CONFIG_ENV). A call whose
|
|
50
51
|
# env: sets one is never read-only (.redefines_shell?). PATH is not
|
|
51
52
|
# one (the tool's own description suggests it); nor are
|
|
52
53
|
# PROMPT_COMMAND (interactive shells only) and ZDOTDIR (zsh in sh
|
|
@@ -250,9 +251,13 @@ module Samagotchi
|
|
|
250
251
|
{}
|
|
251
252
|
end
|
|
252
253
|
|
|
253
|
-
# Whether +env+ sets a SHELL_REDEFINING_ENV variable
|
|
254
|
+
# Whether +env+ sets a SHELL_REDEFINING_ENV variable (or a git config
|
|
255
|
+
# one: ShellGitDirs::CONFIG_ENV).
|
|
254
256
|
def self.redefines_shell?(env)
|
|
255
|
-
env.each_key.any?
|
|
257
|
+
env.each_key.any? do |key|
|
|
258
|
+
SHELL_REDEFINING_ENV.include?(key) || key.start_with?(SHELL_REDEFINING_PREFIX) ||
|
|
259
|
+
key.match?(ShellGitDirs::CONFIG_ENV)
|
|
260
|
+
end
|
|
256
261
|
end
|
|
257
262
|
|
|
258
263
|
# +path+ (a file tool's, as Tools::ToolPath has it) absolute against
|
|
@@ -7,6 +7,7 @@ require_relative "cli/exit"
|
|
|
7
7
|
require_relative "cli/flags"
|
|
8
8
|
require_relative "config"
|
|
9
9
|
require_relative "bridge/card_store"
|
|
10
|
+
require_relative "guardrails/approval"
|
|
10
11
|
require_relative "guardrails/approvals"
|
|
11
12
|
require_relative "guardrails/parent_policy"
|
|
12
13
|
require_relative "host_registry"
|
|
@@ -71,7 +72,7 @@ module Samagotchi
|
|
|
71
72
|
}.freeze
|
|
72
73
|
|
|
73
74
|
# One ask a replay judges: the card's question id, the session it came
|
|
74
|
-
# from, its approval as the card saved it, the label its answer gives,
|
|
75
|
+
# from, its approval as the card saved it (Guardrails::ApprovalFacts), the label its answer gives,
|
|
75
76
|
# and when the user was asked.
|
|
76
77
|
Ask = Data.define(:question_id, :session_id, :approval, :label, :asked_at)
|
|
77
78
|
|
|
@@ -208,17 +209,19 @@ module Samagotchi
|
|
|
208
209
|
|
|
209
210
|
pending = entry[:pending_question]
|
|
210
211
|
return :no_approval unless pending.is_a?(Hash) && pending[:approval].is_a?(Hash)
|
|
212
|
+
|
|
211
213
|
# A participant's turn's ask is never System 1's to judge, live or here
|
|
212
214
|
# (GuardrailWiring#system_one_judge): no record of it is the user's
|
|
213
215
|
# rule-level decision (it offered once only).
|
|
214
|
-
|
|
216
|
+
approval = Guardrails::ApprovalFacts.from_h(pending[:approval])
|
|
217
|
+
return :participant if approval.author
|
|
215
218
|
|
|
216
219
|
question_id = pending[:id].to_s
|
|
217
220
|
return :no_question_id if question_id.empty?
|
|
218
221
|
|
|
219
222
|
origin = judged.question_ids[question_id]
|
|
220
223
|
return origin == "replay" ? :already_replayed : :judged_live if origin
|
|
221
|
-
return :judged_live if judged.take_live(session_id,
|
|
224
|
+
return :judged_live if judged.take_live(session_id, approval)
|
|
222
225
|
|
|
223
226
|
answer = entry[:answer]
|
|
224
227
|
label = if entry[:cancelled]
|
|
@@ -230,15 +233,15 @@ module Samagotchi
|
|
|
230
233
|
# label only where the parent could have allowed: a parent
|
|
231
234
|
# that was not allowed to permit says nothing about the user
|
|
232
235
|
# (docs/guardrails.md, "System 1").
|
|
233
|
-
return :answered_by_parent unless label_for(answer,
|
|
236
|
+
return :answered_by_parent unless label_for(answer, approval.scopes) == "deny"
|
|
234
237
|
return :parent_cannot_allow unless parent_may_permit?(pending, session_id, sessions_dir)
|
|
235
238
|
|
|
236
239
|
"deny"
|
|
237
240
|
else
|
|
238
|
-
label_for(answer,
|
|
241
|
+
label_for(answer, approval.scopes)
|
|
239
242
|
end
|
|
240
243
|
|
|
241
|
-
Ask.new(question_id: question_id, session_id: session_id, approval:
|
|
244
|
+
Ask.new(question_id: question_id, session_id: session_id, approval: approval,
|
|
242
245
|
label: label, asked_at: pending[:created_at])
|
|
243
246
|
end
|
|
244
247
|
|
|
@@ -298,15 +301,15 @@ module Samagotchi
|
|
|
298
301
|
p = answers && answers[SystemOne::ApprovalQuestion::ID]&.noul
|
|
299
302
|
|
|
300
303
|
unless @dry_run
|
|
301
|
-
id = log.judgment(session_id: ask.session_id, rule: ask.approval
|
|
302
|
-
tool: ask.approval
|
|
303
|
-
repo_name: ask.approval
|
|
304
|
+
id = log.judgment(session_id: ask.session_id, rule: ask.approval.rule, source: ask.approval.source,
|
|
305
|
+
tool: ask.approval.tool, **SystemOne::ApprovalQuestion.call_fields(ask.approval),
|
|
306
|
+
repo_name: ask.approval.repo_name, p: p, threshold: threshold, model: client.model,
|
|
304
307
|
latency_ms: latency_ms, decision: "replay", origin: "replay",
|
|
305
308
|
question_id: ask.question_id, asked_at: ask.asked_at)
|
|
306
309
|
log.label(id, ask.label)
|
|
307
310
|
end
|
|
308
311
|
|
|
309
|
-
{ rule: ask.approval
|
|
312
|
+
{ rule: ask.approval.rule.to_s, label: ask.label, p: p, session: ask.session_id }
|
|
310
313
|
end
|
|
311
314
|
|
|
312
315
|
# The session's title, as the ask's state carries it; nil when there is
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "rbconfig"
|
|
4
4
|
require_relative "chi_home"
|
|
5
5
|
require_relative "home_doctor"
|
|
6
|
+
require_relative "home_notice"
|
|
6
7
|
require_relative "cli/command"
|
|
7
8
|
require_relative "cli/exit"
|
|
8
9
|
require_relative "cli/flags"
|
|
@@ -25,8 +26,9 @@ module Samagotchi
|
|
|
25
26
|
set --ruby PATH record that Ruby (its own gem dirs) as the home; it must have samagotchi
|
|
26
27
|
(gem install samagotchi with it first)
|
|
27
28
|
doctor check the home (its Ruby is there and has samagotchi) and what should
|
|
28
|
-
follow it: this chi, the
|
|
29
|
-
this project's Gemfile.lock, mise and the last
|
|
29
|
+
follow it: this chi, the last worker boot, the desktop helper, sessions,
|
|
30
|
+
the tools' chi script, this project's Gemfile.lock, mise and the last
|
|
31
|
+
worker's project env
|
|
30
32
|
Every chi launch (workers, chi web's restart and Update, the macOS helper's next
|
|
31
33
|
chi desktop install or upgrade) boots the home's Ruby with the home's gems. Saved in
|
|
32
34
|
#{Paths.tilde(ChiHome.path)}.
|
|
@@ -42,14 +44,16 @@ module Samagotchi
|
|
|
42
44
|
# @param path [String] home.json
|
|
43
45
|
# @param probe [#call] (ruby) → ChiHome::Record or nil (ChiHome.probe)
|
|
44
46
|
# @param doctor [#call] (home) → HomeDoctor
|
|
47
|
+
# @param bundler [Boolean, nil] running under Bundler (nil: found out)
|
|
45
48
|
def initialize(argv, stdout: $stdout, stderr: $stderr, path: ChiHome.path, gem_spec: InstalledGem.spec,
|
|
46
|
-
env: ENV, probe: nil, doctor: nil)
|
|
49
|
+
env: ENV, probe: nil, doctor: nil, bundler: nil)
|
|
47
50
|
@argv = argv.dup
|
|
48
51
|
@stdout = stdout
|
|
49
52
|
@stderr = stderr
|
|
50
53
|
@path = path
|
|
51
54
|
@gem_spec = gem_spec
|
|
52
55
|
@env = env
|
|
56
|
+
@bundler = bundler.nil? ? ChiHome.under_bundler?(env) : bundler
|
|
53
57
|
@probe = probe || ->(ruby) { ChiHome.probe(ruby, env: env) }
|
|
54
58
|
@doctor = doctor || lambda { |home|
|
|
55
59
|
HomeDoctor.new(home: home, env: env, gem_spec: gem_spec, helper: HomeDoctor.default_helper(env: env))
|
|
@@ -70,6 +74,9 @@ module Samagotchi
|
|
|
70
74
|
rescue ChiHome::Invalid => e
|
|
71
75
|
error_line("#{command_name}: #{e.message} (chi home set rewrites it)")
|
|
72
76
|
CLI::Exit::FAILED
|
|
77
|
+
rescue ChiHome::UnderBundler => e
|
|
78
|
+
error_line("#{command_name}: #{e.message}")
|
|
79
|
+
CLI::Exit::FAILED
|
|
73
80
|
end
|
|
74
81
|
|
|
75
82
|
private
|
|
@@ -98,12 +105,12 @@ module Samagotchi
|
|
|
98
105
|
list
|
|
99
106
|
end
|
|
100
107
|
|
|
101
|
-
def running_home?(home) = @gem_spec && home
|
|
108
|
+
def running_home?(home) = @gem_spec && HomeNotice.home?(home)
|
|
102
109
|
|
|
103
110
|
def running_ruby = "#{RbConfig.ruby}#{" (a checkout)" unless @gem_spec}"
|
|
104
111
|
|
|
105
112
|
def run_set(options)
|
|
106
|
-
home = options[:ruby] ? probed(options[:ruby]) :
|
|
113
|
+
home = options[:ruby] ? probed(options[:ruby]) : running_record
|
|
107
114
|
return CLI::Exit::FAILED unless home
|
|
108
115
|
|
|
109
116
|
if (problem = ChiHome.problem(home))
|
|
@@ -127,13 +134,22 @@ module Samagotchi
|
|
|
127
134
|
error_line("#{command_name}: no Ruby at #{ruby}")
|
|
128
135
|
return nil
|
|
129
136
|
end
|
|
130
|
-
return
|
|
137
|
+
return running_record if ChiHome.real(path) == ChiHome.real(RbConfig.ruby)
|
|
131
138
|
|
|
132
139
|
home = @probe.call(path)
|
|
133
140
|
error_line("#{command_name}: #{ruby} didn't answer as a Ruby") unless home
|
|
134
141
|
home
|
|
135
142
|
end
|
|
136
143
|
|
|
144
|
+
# This process's Ruby and gem path, never under Bundler (the bundle's
|
|
145
|
+
# gems). Another Ruby is probed with Bundler's variables unset.
|
|
146
|
+
# @raise [ChiHome::UnderBundler]
|
|
147
|
+
def running_record
|
|
148
|
+
raise ChiHome::UnderBundler if @bundler
|
|
149
|
+
|
|
150
|
+
ChiHome.from_running(env: @env)
|
|
151
|
+
end
|
|
152
|
+
|
|
137
153
|
# The home's rows and what should follow it (HomeDoctor); exit 1 when
|
|
138
154
|
# one fails.
|
|
139
155
|
def run_doctor
|
|
@@ -7,6 +7,7 @@ require_relative "config"
|
|
|
7
7
|
require_relative "desktop"
|
|
8
8
|
require_relative "home_notice"
|
|
9
9
|
require_relative "installed_gem"
|
|
10
|
+
require_relative "log_line"
|
|
10
11
|
require_relative "log_path"
|
|
11
12
|
require_relative "paths"
|
|
12
13
|
require_relative "project_env"
|
|
@@ -51,18 +52,21 @@ module Samagotchi
|
|
|
51
52
|
def rows
|
|
52
53
|
list = [home_row]
|
|
53
54
|
if @home && !ChiHome.problem(@home)
|
|
54
|
-
list.concat([running_row, helper_row, sessions_row, shim_row].compact)
|
|
55
|
+
list.concat([running_row, boot_row, helper_row, sessions_row, shim_row].compact)
|
|
55
56
|
end
|
|
56
57
|
list.concat([gemfile_row, mise_row, project_env_row].compact)
|
|
57
58
|
end
|
|
58
59
|
|
|
59
60
|
# The desktop helper chi home doctor checks: this machine's, when it
|
|
60
|
-
# is installed.
|
|
61
|
+
# is installed and its chi reads this chi's config dir (home.json's,
|
|
62
|
+
# Desktop::MacOS#reads_this_config?).
|
|
61
63
|
def self.default_helper(env: ENV)
|
|
62
64
|
return nil unless Desktop.supported?
|
|
63
65
|
|
|
64
66
|
helper = Desktop::MacOS.new(env: env, register: false)
|
|
65
|
-
File.file?(helper.launch_path)
|
|
67
|
+
return nil unless File.file?(helper.launch_path)
|
|
68
|
+
|
|
69
|
+
helper.reads_this_config? ? helper : nil
|
|
66
70
|
end
|
|
67
71
|
|
|
68
72
|
private
|
|
@@ -89,6 +93,35 @@ module Samagotchi
|
|
|
89
93
|
text && warn_row("#{text}: launches boot the home; chi home set makes this chi the home")
|
|
90
94
|
end
|
|
91
95
|
|
|
96
|
+
# The newest worker boot in the debug log since the home was set, when
|
|
97
|
+
# it is SessionManager::HOME_BOOT_FAILED's line (the home's Ruby
|
|
98
|
+
# couldn't load chi) rather than a worker's `start`. Without a log
|
|
99
|
+
# file (log.file off) there is no line to read.
|
|
100
|
+
def boot_row
|
|
101
|
+
failed = last_boot_failure
|
|
102
|
+
return nil unless failed
|
|
103
|
+
|
|
104
|
+
ruby = failed.fields["ruby"] || @home.ruby
|
|
105
|
+
fail_row("the last worker (#{failed.ts}, session #{failed.sid}) failed to boot the home's chi on #{ruby}: " \
|
|
106
|
+
"#{failed.fields["error"]}: gem install samagotchi with #{ruby} (or chi home set another Ruby), " \
|
|
107
|
+
"then chi sessions restart #{failed.sid}")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def last_boot_failure
|
|
111
|
+
return nil unless @log_path && File.file?(@log_path)
|
|
112
|
+
|
|
113
|
+
since = Time.iso8601(@home.set_at.to_s)
|
|
114
|
+
newest = nil
|
|
115
|
+
LogLine.each_tail_record(@log_path, bytes: ProjectEnv::LOG_TAIL_BYTES) do |record|
|
|
116
|
+
next unless record.tag == "worker" && %w[start boot_failed].include?(record.event)
|
|
117
|
+
|
|
118
|
+
newest = record if Time.iso8601(record.ts) >= since
|
|
119
|
+
end
|
|
120
|
+
newest if newest&.event == "boot_failed"
|
|
121
|
+
rescue SystemCallError, ArgumentError
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
92
125
|
def helper_row
|
|
93
126
|
return nil unless @helper
|
|
94
127
|
|
|
@@ -48,11 +48,18 @@ module Samagotchi
|
|
|
48
48
|
nil
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
# Whether this chi is +home+'s: it runs on the home's Ruby and gems and
|
|
52
|
+
# is its newest chi. The one rule for "not the home's chi" (this line,
|
|
53
|
+
# chi home doctor, chi home's "running" row, chi self's home row).
|
|
54
|
+
def home?(home, ruby: RbConfig.ruby, gem_dir: Gem.dir, version: VERSION)
|
|
55
|
+
home.running_here?(ruby: ruby, gem_dir: gem_dir) && home.newest_version.to_s == version
|
|
56
|
+
end
|
|
57
|
+
|
|
51
58
|
# This chi isn't the home's: the text, also chi home doctor's row; nil
|
|
52
59
|
# when this process runs on +home+ (or there is none).
|
|
53
60
|
# @return [String, nil]
|
|
54
61
|
def mismatch(home, ruby: RbConfig.ruby, gem_dir: Gem.dir, version: VERSION)
|
|
55
|
-
return nil if home.nil? ||
|
|
62
|
+
return nil if home.nil? || home?(home, ruby: ruby, gem_dir: gem_dir, version: version)
|
|
56
63
|
|
|
57
64
|
where = ruby
|
|
58
65
|
# The same Ruby on other gems (a chruby GEM_HOME): name them both.
|
|
@@ -165,6 +165,11 @@ module Samagotchi
|
|
|
165
165
|
end
|
|
166
166
|
end
|
|
167
167
|
|
|
168
|
+
# Forgets the cached plugin instances (load_plugin builds new ones;
|
|
169
|
+
# Ruby's require cache keeps their classes). For tests: the cache is
|
|
170
|
+
# process-wide.
|
|
171
|
+
def self.reset_cache! = @plugin_cache = nil
|
|
172
|
+
|
|
168
173
|
# The plugin's #call under the entry's error policy: a required
|
|
169
174
|
# before_tool_call hook that raises denies the call; otherwise
|
|
170
175
|
# on_error: log warns and skip is silent.
|
|
@@ -429,8 +429,8 @@ module Samagotchi
|
|
|
429
429
|
# The shape the engine persists: role and content (parts stay an
|
|
430
430
|
# Array), plus a model turn's tool_calls and thinking (the host's
|
|
431
431
|
# reasoning, sent back as #wire_messages says), a result's tool_call_id, the image
|
|
432
|
-
# refs of a user message or a tool result, a prompt's turn_id
|
|
433
|
-
# never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
|
|
432
|
+
# refs of a user message or a tool result, a prompt's turn_id and its
|
|
433
|
+
# Verify/Revise anchors (Engine, never sent) and a wake turn's report's turn_start (Steer, never sent), and a plugin tool result's tool_params and tool_labels
|
|
434
434
|
# (the live row's params line and label, never sent back), an
|
|
435
435
|
# edit/write result's tool_diffs (never sent), an empty-answer
|
|
436
436
|
# note's marker (TurnNote.empty, never sent), and an answer's display
|
|
@@ -448,6 +448,7 @@ module Samagotchi
|
|
|
448
448
|
message[:turn_id] = entry[:turn_id] if entry[:turn_id]
|
|
449
449
|
message[:turn_start] = true if entry[:turn_start] == true
|
|
450
450
|
message[:verify] = entry[:verify] if entry[:verify].is_a?(Hash)
|
|
451
|
+
message[:revises] = entry[:revises] if entry[:revises].is_a?(Hash)
|
|
451
452
|
message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
|
|
452
453
|
message[:anthropic_content] = entry[:anthropic_content] if entry[:anthropic_content].is_a?(Array)
|
|
453
454
|
ToolResponse::SAVED_KEYS.each { |key| message[key] = entry[key] if entry[key] }
|
|
@@ -706,7 +707,7 @@ module Samagotchi
|
|
|
706
707
|
end
|
|
707
708
|
|
|
708
709
|
# The host's reasoning, kept on the model message as +thinking+ for
|
|
709
|
-
# the web turn
|
|
710
|
+
# the web turn block's reload (the whole of it, as the live block
|
|
710
711
|
# shows), and sent back to the model as ChatLoop#wire_messages says
|
|
711
712
|
# (reasoning_history). A wire that sends its blocks back keeps them
|
|
712
713
|
# as +anthropic_content+ (ChatResponse#provider_content: string
|
data/lib/samagotchi/log_line.rb
CHANGED
|
@@ -104,6 +104,18 @@ module Samagotchi
|
|
|
104
104
|
line.start_with?(INDENT)
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# Yields each Record in the last +bytes+ of the log at +path+ (a torn
|
|
108
|
+
# first line skipped): chi home doctor's readers.
|
|
109
|
+
# @raise [SystemCallError]
|
|
110
|
+
def each_tail_record(path, bytes:, &)
|
|
111
|
+
File.open(path, "rb") do |io|
|
|
112
|
+
size = io.size
|
|
113
|
+
io.seek([size - bytes, 0].max)
|
|
114
|
+
io.gets if size > bytes
|
|
115
|
+
each_record(io, &)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
107
119
|
# Yields each Record of a log (payload lines joined onto theirs). Lines
|
|
108
120
|
# that are neither (an older format, a torn write) go to on_invalid.
|
|
109
121
|
def each_record(io, on_invalid: nil)
|
|
@@ -13,7 +13,7 @@ require_relative "row_refs"
|
|
|
13
13
|
require_relative "tool_output_cut"
|
|
14
14
|
|
|
15
15
|
module Samagotchi
|
|
16
|
-
# What a saved assistant message did, for
|
|
16
|
+
# What a saved assistant message did, for the web turn block's reload: its
|
|
17
17
|
# thinking and its tool calls (name, the params line the live row shows,
|
|
18
18
|
# the output from the tool_response messages after it). Two storage
|
|
19
19
|
# shapes, best effort:
|