samagotchi 0.134.0 → 0.136.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 +78 -1
- data/bin/chi +2 -3
- data/docs/architecture.md +6 -3
- data/docs/cli.md +43 -38
- data/docs/configuration.md +11 -9
- data/docs/guardrails.md +53 -9
- data/docs/internals/engine.md +6 -3
- data/docs/internals/participants.md +1 -1
- data/docs/internals/prompt-caching.md +1 -1
- data/docs/internals/web-frontend.md +263 -0
- data/docs/internals/working-set.md +2 -2
- data/docs/sessions.md +5 -3
- data/docs/testing.md +36 -6
- 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/config.rb +0 -2
- data/lib/samagotchi/engine.rb +69 -12
- data/lib/samagotchi/guardrail_wiring.rb +8 -6
- data/lib/samagotchi/guardrails/approval.rb +41 -5
- data/lib/samagotchi/guardrails/implicit_writers.rb +19 -4
- data/lib/samagotchi/guardrails/pair_boundary.rb +5 -1
- data/lib/samagotchi/guardrails/pair_command.rb +387 -78
- data/lib/samagotchi/guardrails/parent_approvals.rb +12 -15
- data/lib/samagotchi/guardrails/read_only_shell.rb +24 -2
- data/lib/samagotchi/guardrails/shared_git.rb +1 -1
- data/lib/samagotchi/guardrails/shell_git_dirs.rb +1 -1
- data/lib/samagotchi/guardrails/shell_lex.rb +22 -0
- data/lib/samagotchi/guardrails_command.rb +13 -10
- data/lib/samagotchi/llm/chat_loop.rb +4 -3
- data/lib/samagotchi/message_parts.rb +1 -1
- data/lib/samagotchi/revise_anchor.rb +33 -0
- 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 +13 -15
- 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/verify_anchor.rb +5 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +6 -18
- data/lib/samagotchi/web/message_display.rb +33 -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 +100 -31
- data/lib/samagotchi/web/public/app.js +451 -367
- data/lib/samagotchi/web/public/bubbles.js +30 -10
- data/lib/samagotchi/web/public/cards_place.js +4 -2
- data/lib/samagotchi/web/public/chip_popover.js +2 -2
- data/lib/samagotchi/web/public/composer.css +8 -0
- 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 +10 -6
- data/lib/samagotchi/web/public/index.html +105 -53
- data/lib/samagotchi/web/public/info_bar.js +11 -12
- data/lib/samagotchi/web/public/info_bar_model.js +8 -10
- data/lib/samagotchi/web/public/latest.js +46 -0
- data/lib/samagotchi/web/public/level_chip.js +33 -95
- 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 +13 -144
- 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/reasoning_history_chip.js +8 -34
- data/lib/samagotchi/web/public/restore.js +2 -0
- data/lib/samagotchi/web/public/revise_stack.js +378 -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 +9 -5
- data/lib/samagotchi/web/public/session_store.js +191 -0
- data/lib/samagotchi/web/public/settings_chip.js +212 -0
- data/lib/samagotchi/web/public/settings_panel.js +121 -0
- data/lib/samagotchi/web/public/shell_command.js +26 -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_settings.js +25 -88
- 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/thinking_chip.js +7 -45
- data/lib/samagotchi/web/public/time_gaps.js +5 -2
- data/lib/samagotchi/web/public/timing.js +15 -5
- data/lib/samagotchi/web/public/toolkit_chip.js +37 -21
- data/lib/samagotchi/web/public/{turn_view.js → turn_block.js} +33 -93
- data/lib/samagotchi/web/public/turn_events.js +3 -3
- data/lib/samagotchi/web/public/turn_html.js +20 -16
- data/lib/samagotchi/web/public/turn_model.js +6 -6
- data/lib/samagotchi/web/public/verdict_fresh.js +2 -0
- 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/web/session_create.rb +1 -1
- data/lib/samagotchi/web/session_show.rb +6 -3
- data/lib/samagotchi/worker.rb +9 -9
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +15 -3
- data/lib/samagotchi/web/public/hold.js +0 -78
|
@@ -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
|
|
@@ -231,6 +233,7 @@ module Samagotchi
|
|
|
231
233
|
|
|
232
234
|
writes&.walk&.step(args)
|
|
233
235
|
verb = args.shift
|
|
236
|
+
return args.last == "]" && test_expr(args[0...-1]) if verb == "["
|
|
234
237
|
return false unless verb&.match?(/\A[a-z][\w.-]*\z/)
|
|
235
238
|
return true if PLAIN.include?(verb)
|
|
236
239
|
return WRITE_CHECKS[verb].call(args, writes) if writes && WRITE_CHECKS[verb]
|
|
@@ -276,6 +279,25 @@ module Samagotchi
|
|
|
276
279
|
|
|
277
280
|
def rg(args) = args.none? { |a| a.start_with?("--pre") }
|
|
278
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
|
+
|
|
279
301
|
FIND_ACTIONS = %w[-delete -exec -execdir -ok -okdir -fprint -fprint0 -fprintf -fls].freeze
|
|
280
302
|
|
|
281
303
|
def find(args) = args.none? { |a| FIND_ACTIONS.any? { |action| a.start_with?(action) } }
|
|
@@ -490,7 +512,7 @@ module Samagotchi
|
|
|
490
512
|
CHECKS = {
|
|
491
513
|
"rg" => method(:rg), "find" => method(:find), "tree" => method(:tree), "file" => method(:file),
|
|
492
514
|
"sort" => method(:sort), "uniq" => method(:uniq), "sed" => method(:sed), "awk" => method(:awk),
|
|
493
|
-
"git" => method(:git), "sqlite3" => method(:sqlite3), "gh" => method(:gh)
|
|
515
|
+
"git" => method(:git), "sqlite3" => method(:sqlite3), "gh" => method(:gh), "test" => method(:test_expr)
|
|
494
516
|
}.freeze
|
|
495
517
|
|
|
496
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?
|
|
@@ -97,7 +97,7 @@ module Samagotchi
|
|
|
97
97
|
@pushed << @dirs
|
|
98
98
|
@dirs = args.first ? dirs_after(args.first) : [nil]
|
|
99
99
|
when "popd" then @dirs = @pushed.pop unless @pushed.empty?
|
|
100
|
-
else git(args, env) if
|
|
100
|
+
else git(args, env) if ShellLex.program?(verb, "git")
|
|
101
101
|
end
|
|
102
102
|
end
|
|
103
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)
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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:
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "verify_anchor"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# The earlier turn a Revise turn rewrites the answer of, carried only by
|
|
7
|
+
# a Revise turn (TurnAnchors). Its stamp on the turn's opening message
|
|
8
|
+
# is display metadata, never in the prompt (Engine#revision_target).
|
|
9
|
+
ReviseAnchor = Data.define(:turn_id) do
|
|
10
|
+
def self.from(value)
|
|
11
|
+
return value if value.is_a?(self)
|
|
12
|
+
return nil unless value.is_a?(Hash)
|
|
13
|
+
|
|
14
|
+
fields = value.to_h { |key, entry| [key.to_s, entry] }
|
|
15
|
+
return nil unless fields.keys == %w[turn_id]
|
|
16
|
+
|
|
17
|
+
turn_id = fields["turn_id"]
|
|
18
|
+
return nil unless turn_id.is_a?(String) && VerifyAnchor::UUID.match?(turn_id)
|
|
19
|
+
|
|
20
|
+
new(turn_id:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def to_h = { turn_id: }
|
|
24
|
+
|
|
25
|
+
def to_file = { "turn_id" => turn_id }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
ReviseAnchor::FEATURE = "revise"
|
|
29
|
+
ReviseAnchor::UNAVAILABLE = {
|
|
30
|
+
"error" => "revise_unavailable",
|
|
31
|
+
"detail" => "this session's worker predates Revise turns; restart this session to use Revise"
|
|
32
|
+
}.freeze
|
|
33
|
+
end
|
|
@@ -12,8 +12,10 @@ require_relative "steer"
|
|
|
12
12
|
require_relative "turn_origin"
|
|
13
13
|
|
|
14
14
|
module Samagotchi
|
|
15
|
-
# Loaded on first use (
|
|
16
|
-
# load budget
|
|
15
|
+
# Loaded on first use (any turn's input, a Verify or a Revise turn's
|
|
16
|
+
# anchor): keeps the bundle test kit's load budget
|
|
17
|
+
# (spec/samagotchi/testing/sandbox_spec.rb).
|
|
18
|
+
autoload :TurnAnchors, File.expand_path("turn_anchors", __dir__)
|
|
17
19
|
autoload :VerifyAnchor, File.expand_path("verify_anchor", __dir__)
|
|
18
20
|
|
|
19
21
|
# A session directory's file inbox and outbox (see SessionManager for the
|
|
@@ -54,10 +56,12 @@ module Samagotchi
|
|
|
54
56
|
# @param author [String, nil] who wrote the message when it isn't the
|
|
55
57
|
# session's owner (Web::Principal#author); nil, the owner's, is never
|
|
56
58
|
# written, so an owner's file is as it always was
|
|
59
|
+
# @param anchors [TurnAnchors, Hash, nil] a Verify turn's claim, a Revise
|
|
60
|
+
# turn's draft (TurnAnchors#to_file's keys; none: none written)
|
|
57
61
|
# @param now [Time] the time the file is named by (a spec's clock)
|
|
58
62
|
# @return [String, false] the input file's path, or false
|
|
59
63
|
def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [],
|
|
60
|
-
delivery: nil, author: nil,
|
|
64
|
+
delivery: nil, author: nil, anchors: nil, now: Time.now)
|
|
61
65
|
images = Array(images)
|
|
62
66
|
input_dir = File.join(session_dir, INPUT_DIR)
|
|
63
67
|
FileUtils.mkdir_p(input_dir)
|
|
@@ -67,7 +71,8 @@ module Samagotchi
|
|
|
67
71
|
"no_interrupt" => (no_interrupt ? true : nil),
|
|
68
72
|
"delivery" => (Delivery.queue?(delivery) ? Delivery::QUEUE : nil),
|
|
69
73
|
"images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }),
|
|
70
|
-
"author" => author
|
|
74
|
+
"author" => author }.compact
|
|
75
|
+
record.merge!(TurnAnchors.from(anchors).to_file) if anchors
|
|
71
76
|
AtomicFile.write(path, JSON.generate(record))
|
|
72
77
|
path
|
|
73
78
|
rescue StandardError
|
|
@@ -231,10 +236,12 @@ module Samagotchi
|
|
|
231
236
|
# no sender), whether its turn runs with the raised iteration limit
|
|
232
237
|
# (+no_interrupt+, --no-interrupt), its +images+ ({file:, name:} refs),
|
|
233
238
|
# and its +delivery+ ("queue" or nil; a file without the key is one an
|
|
234
|
-
# older chi wrote, so nil)
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
239
|
+
# older chi wrote, so nil), and its +anchors+ (TurnAnchors, nil when it
|
|
240
|
+
# names none: TurnAnchors loads on first use, not with UNREADABLE_INPUT).
|
|
241
|
+
Input = Data.define(:prompt, :origin, :no_interrupt, :images, :delivery, :anchors) do
|
|
242
|
+
def initialize(prompt:, origin: nil, no_interrupt: false, images: [], delivery: nil, anchors: nil)
|
|
243
|
+
anchors &&= TurnAnchors.from(anchors)
|
|
244
|
+
super(prompt:, origin: TurnOrigin.of(origin), no_interrupt:, images:, delivery:, anchors: anchors && anchors.empty? ? nil : anchors)
|
|
238
245
|
end
|
|
239
246
|
end
|
|
240
247
|
# A file that doesn't parse, or isn't a JSON object: no prompt.
|
|
@@ -250,7 +257,7 @@ module Samagotchi
|
|
|
250
257
|
origin = TurnOrigin.from(data.slice("client_id", "enqueued_id", "author"))
|
|
251
258
|
images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
|
|
252
259
|
Input.new(prompt: data["prompt"].to_s, origin: origin, no_interrupt: data["no_interrupt"] == true,
|
|
253
|
-
images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil,
|
|
260
|
+
images: images, delivery: Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil, anchors: data)
|
|
254
261
|
rescue JSON::ParserError
|
|
255
262
|
UNREADABLE_INPUT
|
|
256
263
|
end
|
|
@@ -282,11 +289,12 @@ module Samagotchi
|
|
|
282
289
|
true
|
|
283
290
|
end
|
|
284
291
|
|
|
285
|
-
# A parsed input file's #waits_for_turn_end?. A Verify
|
|
286
|
-
# merged into a running turn as a steer, its anchor would be
|
|
292
|
+
# A parsed input file's #waits_for_turn_end?. A Verify or a Revise turn
|
|
293
|
+
# waits too: merged into a running turn as a steer, its anchor would be
|
|
294
|
+
# lost.
|
|
287
295
|
def self.turn_end_data?(data)
|
|
288
296
|
Array(data["images"]).any? || Delivery.queue?(data["delivery"]) || Steer.participant_author?(data["author"]) ||
|
|
289
|
-
|
|
297
|
+
TurnAnchors.named_in?(data)
|
|
290
298
|
end
|
|
291
299
|
|
|
292
300
|
# Whether a message waits that a running turn's next boundary merges and
|
|
@@ -1368,10 +1368,11 @@ module Samagotchi
|
|
|
1368
1368
|
# session's images/
|
|
1369
1369
|
# @return [String, false] the input file's path, or false.
|
|
1370
1370
|
def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
|
|
1371
|
-
images: [], delivery: nil, author: nil,
|
|
1371
|
+
images: [], delivery: nil, author: nil, anchors: nil)
|
|
1372
1372
|
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1373
1373
|
SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
|
|
1374
|
-
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author,
|
|
1374
|
+
no_interrupt: no_interrupt, images: images, delivery: delivery, author: author,
|
|
1375
|
+
anchors: anchors)
|
|
1375
1376
|
end
|
|
1376
1377
|
|
|
1377
1378
|
# How long a turn waits for the Bridge of a worker a resume just spawned.
|
|
@@ -1440,6 +1441,9 @@ module Samagotchi
|
|
|
1440
1441
|
# @param pending_limit [Integer, nil] with +author+: how many of their
|
|
1441
1442
|
# turns may be running or waiting (the Bridge counts them; with no
|
|
1442
1443
|
# worker, their waiting files): :refused 429 too_many_turns past it
|
|
1444
|
+
# @param anchors [TurnAnchors, Hash, nil] a Verify turn's claim, a Revise
|
|
1445
|
+
# turn's draft: each refused (409, its UNAVAILABLE) by a worker without
|
|
1446
|
+
# its feature
|
|
1443
1447
|
# @param manager [#resume_session, #write_turn_input] this class, or a
|
|
1444
1448
|
# stand-in (the web's specs); #session_owner is optional
|
|
1445
1449
|
# @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
|
|
@@ -1453,8 +1457,8 @@ module Samagotchi
|
|
|
1453
1457
|
# @raise [OwnedByTUI] a chi REPL owns the session
|
|
1454
1458
|
# @raise [ArgumentError] no such session
|
|
1455
1459
|
def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, no_interrupt: false,
|
|
1456
|
-
author: nil, pending_limit: nil,
|
|
1457
|
-
|
|
1460
|
+
author: nil, pending_limit: nil, anchors: nil, state_dir: nil, manager: self, bridge: nil)
|
|
1461
|
+
anchors = TurnAnchors.from(anchors)
|
|
1458
1462
|
session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
|
|
1459
1463
|
bridge ||= lambda do
|
|
1460
1464
|
link = WorkerLink.await(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT)
|
|
@@ -1462,7 +1466,9 @@ module Samagotchi
|
|
|
1462
1466
|
end
|
|
1463
1467
|
manager.resume_session(session_id, state_dir: state_dir)
|
|
1464
1468
|
if (client = bridge.call)
|
|
1465
|
-
|
|
1469
|
+
if (missing = anchors.present.find { |anchor| !worker_has?(client, anchor.class::FEATURE) })
|
|
1470
|
+
return DeliverResult.new(status: :refused, code: 409, ack: missing.class::UNAVAILABLE)
|
|
1471
|
+
end
|
|
1466
1472
|
# A worker from before queueing (no "delivery" feature) ignores the
|
|
1467
1473
|
# field or refuses it; either way a file would steer the running turn.
|
|
1468
1474
|
return queue_unavailable if Delivery.queue?(delivery) && !worker_has?(client, Delivery::QUEUE_FEATURE)
|
|
@@ -1477,7 +1483,7 @@ module Samagotchi
|
|
|
1477
1483
|
options[:no_interrupt] = true if no_interrupt
|
|
1478
1484
|
options[:author] = author if author
|
|
1479
1485
|
options[:pending_limit] = pending_limit if author && pending_limit
|
|
1480
|
-
options[:
|
|
1486
|
+
options[:anchors] = anchors unless anchors.empty?
|
|
1481
1487
|
reply = client.post_turn(**options)
|
|
1482
1488
|
ack = reply.json
|
|
1483
1489
|
return DeliverResult.new(status: :accepted, via: :bridge, ack: ack) if reply.status == 202 && ack.is_a?(Hash)
|
|
@@ -1515,7 +1521,7 @@ module Samagotchi
|
|
|
1515
1521
|
input[:delivery] = delivery unless Delivery.next_step?(delivery)
|
|
1516
1522
|
input[:no_interrupt] = true if no_interrupt
|
|
1517
1523
|
input[:author] = author if author
|
|
1518
|
-
input[:
|
|
1524
|
+
input[:anchors] = anchors unless anchors.empty?
|
|
1519
1525
|
path = participant_queue_lock(session_dir, author && pending_limit) do
|
|
1520
1526
|
# No worker: the participant's waiting files are all their turns,
|
|
1521
1527
|
# counted and written under one lock (the Bridge counts under its).
|
|
@@ -1581,10 +1587,6 @@ module Samagotchi
|
|
|
1581
1587
|
DeliverResult.new(status: :refused, code: 409, ack: Delivery::REVIEW_UNAVAILABLE)
|
|
1582
1588
|
end
|
|
1583
1589
|
|
|
1584
|
-
private_class_method def self.verify_unavailable
|
|
1585
|
-
DeliverResult.new(status: :refused, code: 409, ack: VerifyAnchor::UNAVAILABLE)
|
|
1586
|
-
end
|
|
1587
|
-
|
|
1588
1590
|
private_class_method def self.turn_timeout
|
|
1589
1591
|
DeliverResult.new(status: :timeout, via: :bridge,
|
|
1590
1592
|
ack: { "error" => "worker_timeout",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
|
|
5
|
+
require_relative "../guardrails/approval"
|
|
5
6
|
require_relative "question"
|
|
6
7
|
require_relative "similar"
|
|
7
8
|
|
|
@@ -35,29 +36,31 @@ module Samagotchi
|
|
|
35
36
|
Question.new(id: ID, type: "noul", instructions: INSTRUCTIONS)
|
|
36
37
|
end
|
|
37
38
|
|
|
38
|
-
# @param approval [Hash] payload[:approval]:
|
|
39
|
+
# @param approval [Guardrails::ApprovalFacts, Hash] payload[:approval]:
|
|
40
|
+
# the ask the user is shown (a saved card's hash too)
|
|
39
41
|
# @param session_title [String, nil] the session's title, when it has one
|
|
40
42
|
# @param entries [Array<Hash>] SystemOne::Log#entries: the judgments the
|
|
41
43
|
# user has answered so far, read once per ask and shared with the
|
|
42
44
|
# record gate (GuardrailWiring#system_one_judge)
|
|
43
45
|
# @return [Hash] the state, with the fields the ask didn't give left out
|
|
44
46
|
def self.state(approval, session_title: nil, entries: nil)
|
|
47
|
+
approval = Guardrails::ApprovalFacts.from_h(approval)
|
|
45
48
|
{
|
|
46
|
-
"rule" => approval
|
|
47
|
-
"source" => approval
|
|
48
|
-
"reason" => approval
|
|
49
|
-
"tool" => approval
|
|
50
|
-
"command" => cut(approval
|
|
51
|
-
"paths" => Array(approval
|
|
52
|
-
"change" => change_text(approval
|
|
53
|
-
"cwd" => approval
|
|
54
|
-
"repo_name" => approval
|
|
55
|
-
"branch" => approval
|
|
49
|
+
"rule" => approval.rule,
|
|
50
|
+
"source" => approval.source,
|
|
51
|
+
"reason" => approval.reason,
|
|
52
|
+
"tool" => approval.tool,
|
|
53
|
+
"command" => cut(approval.command),
|
|
54
|
+
"paths" => Array(approval.paths).map { |path| cut(path) },
|
|
55
|
+
"change" => change_text(approval.preview),
|
|
56
|
+
"cwd" => approval.cwd,
|
|
57
|
+
"repo_name" => approval.repo_name,
|
|
58
|
+
"branch" => approval.branch,
|
|
56
59
|
"session" => session_title,
|
|
57
60
|
# Which memory entry the ask is about, when it is a memory ask.
|
|
58
|
-
"memory" => memory_facts(approval
|
|
61
|
+
"memory" => memory_facts(approval.memory),
|
|
59
62
|
# The asks this one looks like, and what the user did about them.
|
|
60
|
-
"examples" => Similar.select(entries: entries, rule: approval
|
|
63
|
+
"examples" => Similar.select(entries: entries, rule: approval.rule, repo_name: approval.repo_name,
|
|
61
64
|
call_text: call_text(approval))
|
|
62
65
|
}.reject do |_key, value|
|
|
63
66
|
value.nil? || (value.is_a?(String) && value.strip.empty?) ||
|
|
@@ -67,14 +70,16 @@ module Samagotchi
|
|
|
67
70
|
|
|
68
71
|
# What the call is, whole: the command, the paths, or a plugin tool's
|
|
69
72
|
# arguments.
|
|
73
|
+
# @param approval [Guardrails::ApprovalFacts, Hash] as built or saved
|
|
70
74
|
def self.call_text(approval)
|
|
71
|
-
|
|
75
|
+
approval = Guardrails::ApprovalFacts.from_h(approval)
|
|
76
|
+
command = approval.command
|
|
72
77
|
return command.to_s if command && !command.to_s.empty?
|
|
73
78
|
|
|
74
|
-
paths = Array(approval
|
|
79
|
+
paths = Array(approval.paths)
|
|
75
80
|
return paths.join("\n") unless paths.empty?
|
|
76
81
|
|
|
77
|
-
approval
|
|
82
|
+
approval.args.to_s
|
|
78
83
|
end
|
|
79
84
|
|
|
80
85
|
# The judgment log's two fields for the call: what it was, cut to
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../config"
|
|
4
4
|
require_relative "../memory_paths"
|
|
5
5
|
require_relative "../paths"
|
|
6
|
+
require_relative "../guardrails/approval"
|
|
6
7
|
require_relative "../guardrails/chi_dirs"
|
|
7
8
|
require_relative "../guardrails/path_resolve"
|
|
8
9
|
require_relative "../guardrails/pair_command"
|
|
@@ -86,20 +87,21 @@ module Samagotchi
|
|
|
86
87
|
|
|
87
88
|
module_function
|
|
88
89
|
|
|
89
|
-
# @param approval [Hash, nil] the ask's
|
|
90
|
-
# paths, command, cwd)
|
|
90
|
+
# @param approval [Guardrails::ApprovalFacts, Hash, nil] the ask's
|
|
91
|
+
# approval facts (rule, tool, paths, command, cwd), as built or saved
|
|
91
92
|
# @param targets [Guardrails::Targets, nil] the call's targets, for its
|
|
92
93
|
# env: (Targets#shell_envs, #redefines_shell?)
|
|
93
94
|
# @return [Boolean] whether System 1 must not answer this ask
|
|
94
95
|
def refuse?(approval, targets: nil)
|
|
95
|
-
|
|
96
|
-
return
|
|
96
|
+
approval = Guardrails::ApprovalFacts.from_h(approval)
|
|
97
|
+
return false if approval.nil?
|
|
98
|
+
return true if NEVER_AUTO.include?(approval.rule.to_s)
|
|
97
99
|
# An env: that redefines the shell (BASH_ENV, BASH_FUNC_…) runs what
|
|
98
100
|
# no word of the command says.
|
|
99
101
|
return true if targets&.redefines_shell?
|
|
100
102
|
|
|
101
|
-
command =
|
|
102
|
-
words = command.empty? ? nil : words(command, cwd:
|
|
103
|
+
command = approval.command.to_s
|
|
104
|
+
words = command.empty? ? nil : words(command, cwd: approval.cwd, targets: targets)
|
|
103
105
|
return true if words&.chi
|
|
104
106
|
return true if chi_paths?(approval)
|
|
105
107
|
# The test that makes an ask user_only (ParentApprovals#chi_files?:
|
|
@@ -116,7 +118,7 @@ module Samagotchi
|
|
|
116
118
|
memories = Guardrails::PathResolve.real(MemoryPaths.system_dir)
|
|
117
119
|
bundles = Guardrails::PathResolve.real(MemoryPaths.bundles_dir)
|
|
118
120
|
dirs = chi_dirs
|
|
119
|
-
Array(
|
|
121
|
+
Array(approval.paths).map(&:to_s).reject(&:empty?).any? do |path|
|
|
120
122
|
Guardrails::PathResolve.reals(path).any? do |real|
|
|
121
123
|
dirs.any? { |dir| Guardrails::PathResolve.within?(real, dir) } && !memory?(real, memories, bundles)
|
|
122
124
|
end
|
|
@@ -129,12 +131,12 @@ module Samagotchi
|
|
|
129
131
|
def beyond_memories(approval, exempt:)
|
|
130
132
|
memories = Guardrails::PathResolve.real(MemoryPaths.system_dir)
|
|
131
133
|
bundles = Guardrails::PathResolve.real(MemoryPaths.bundles_dir)
|
|
132
|
-
paths = Array(
|
|
134
|
+
paths = Array(approval.paths).map(&:to_s).reject do |path|
|
|
133
135
|
Guardrails::PathResolve.reals(path).all? { |real| memory?(real, memories, bundles) }
|
|
134
136
|
end
|
|
135
|
-
command =
|
|
137
|
+
command = approval.command.to_s
|
|
136
138
|
command = command.gsub(memories_text) { |text| text.include?("/.bundles") ? text : "" } if exempt
|
|
137
|
-
|
|
139
|
+
approval.with(paths: paths, command: command)
|
|
138
140
|
end
|
|
139
141
|
|
|
140
142
|
# Whether the command's memories text may be taken out: no command,
|
|
@@ -211,7 +213,7 @@ module Samagotchi
|
|
|
211
213
|
simple.each_with_index.any? do |word, i|
|
|
212
214
|
next false if word.include?("\0")
|
|
213
215
|
|
|
214
|
-
verbs = FILE_RUNNERS.find { |pattern, _|
|
|
216
|
+
verbs = FILE_RUNNERS.find { |pattern, _| Guardrails::ShellLex.program_name(word).match?(pattern) }&.last
|
|
215
217
|
verbs && (verbs.empty? || simple[(i + 1)..].intersect?(verbs))
|
|
216
218
|
end
|
|
217
219
|
end
|
|
@@ -240,10 +242,6 @@ module Samagotchi
|
|
|
240
242
|
word = WORD_END.sub("[", "[^")
|
|
241
243
|
Regexp.new("(?:#{prefixes.join("|")})/memories(?=/|#{WORD_END}|\\z)(?:/#{word}*)?")
|
|
242
244
|
end
|
|
243
|
-
|
|
244
|
-
def fetch(hash, key)
|
|
245
|
-
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
246
|
-
end
|
|
247
245
|
end
|
|
248
246
|
end
|
|
249
247
|
end
|