samagotchi 0.92.0 → 0.94.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -1
- data/README.md +1 -1
- data/bin/chi +4 -4
- data/docs/architecture.md +4 -4
- data/docs/cli.md +1 -1
- data/docs/guardrails.md +4 -5
- data/docs/hooks.md +1 -1
- data/docs/internals/bundles.md +1 -1
- data/docs/internals/engine.md +6 -0
- data/docs/memory.md +26 -1
- data/docs/plugins.md +37 -0
- data/docs/releasing.md +2 -2
- data/docs/sessions.md +1 -1
- data/docs/testing.md +11 -2
- data/lib/samagotchi/bootstrap/probe.rb +2 -2
- data/lib/samagotchi/bootstrap_command.rb +1 -0
- data/lib/samagotchi/bundle_command.rb +1 -0
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
- data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
- data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
- data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +12 -14
- data/lib/samagotchi/client_id.rb +18 -0
- data/lib/samagotchi/config/restart_watch.rb +112 -0
- data/lib/samagotchi/config.rb +18 -59
- data/lib/samagotchi/engine.rb +4 -14
- data/lib/samagotchi/guardrails/data_readers.rb +34 -30
- data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
- data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
- data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
- data/lib/samagotchi/hook_question.rb +21 -0
- data/lib/samagotchi/hook_routes.rb +6 -6
- data/lib/samagotchi/hooks/registry.rb +5 -5
- data/lib/samagotchi/host_registry.rb +1 -1
- data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
- data/lib/samagotchi/memory_bundle/profile.rb +22 -4
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
- data/lib/samagotchi/parent_answers_command.rb +6 -6
- data/lib/samagotchi/plugin/context.rb +6 -5
- data/lib/samagotchi/plugin/http_transport.rb +14 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -3
- data/lib/samagotchi/question_desk.rb +12 -9
- data/lib/samagotchi/reply_wait/poll.rb +267 -0
- data/lib/samagotchi/reply_wait.rb +47 -190
- data/lib/samagotchi/send_command/parent_gate.rb +83 -0
- data/lib/samagotchi/send_command/wait_target.rb +55 -0
- data/lib/samagotchi/send_command.rb +31 -100
- data/lib/samagotchi/session.rb +10 -9
- data/lib/samagotchi/session_inbox.rb +31 -8
- data/lib/samagotchi/session_manager.rb +48 -15
- data/lib/samagotchi/session_metrics.rb +22 -3
- data/lib/samagotchi/sessions_command.rb +4 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
- data/lib/samagotchi/testing/bundle.rb +1 -1
- data/lib/samagotchi/testing/host.rb +2 -8
- data/lib/samagotchi/testing/recorder.rb +6 -9
- data/lib/samagotchi/testing/sandbox.rb +26 -0
- data/lib/samagotchi/tools/delegate.rb +10 -10
- data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
- data/lib/samagotchi/tools/delegate_relay.rb +31 -16
- data/lib/samagotchi/tools/delegate_wait.rb +4 -4
- data/lib/samagotchi/update_command.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +46 -63
- data/lib/samagotchi/web/public/app.js +137 -638
- data/lib/samagotchi/web/public/dock_reserve.js +103 -0
- data/lib/samagotchi/web/public/index.html +4 -4
- data/lib/samagotchi/web/public/send_turn.js +173 -0
- data/lib/samagotchi/web/public/session_actions.js +203 -0
- data/lib/samagotchi/web/public/start_page.js +236 -0
- data/lib/samagotchi/web/public/toast_place.js +1 -1
- metadata +14 -1
|
@@ -194,12 +194,10 @@ module Samagotchi
|
|
|
194
194
|
def commit
|
|
195
195
|
@taken.each_value do |report|
|
|
196
196
|
Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
|
|
197
|
-
next cursor unless cursor.report_claim&.
|
|
197
|
+
next cursor unless cursor.report_claim&.id == report.claim_id
|
|
198
198
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
current = cursor.with(report_claim: nil)
|
|
202
|
-
current == before ? after : current
|
|
199
|
+
current = cursor.without_claim
|
|
200
|
+
current == report.before.without_claim ? report.after.without_claim : current
|
|
203
201
|
end
|
|
204
202
|
delete_rings(report.rings)
|
|
205
203
|
rescue StandardError => e
|
|
@@ -212,7 +210,7 @@ module Samagotchi
|
|
|
212
210
|
def release
|
|
213
211
|
@taken.each_value do |report|
|
|
214
212
|
Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
|
|
215
|
-
cursor.report_claim&.
|
|
213
|
+
cursor.report_claim&.id == report.claim_id ? cursor.without_claim : cursor
|
|
216
214
|
end
|
|
217
215
|
end
|
|
218
216
|
@taken = {}
|
|
@@ -234,11 +232,11 @@ module Samagotchi
|
|
|
234
232
|
return report.with(relay: nil) unless relay
|
|
235
233
|
|
|
236
234
|
question = report.relay
|
|
237
|
-
|
|
235
|
+
label = Tools::DelegateRelay.label(question)
|
|
238
236
|
line = if Session.load(report.child_id, state_dir: @state_dir).pending_question&.id.to_s != question.id.to_s
|
|
239
|
-
Tools::DelegateRelay.line(
|
|
237
|
+
Tools::DelegateRelay.line(label, "answered on the child")
|
|
240
238
|
elsif relay.relay_desk.settled?(report.child_id, question.id)
|
|
241
|
-
Tools::DelegateRelay.line(
|
|
239
|
+
Tools::DelegateRelay.line(label, "answered on a relay already; it waits for the user on the child")
|
|
242
240
|
else
|
|
243
241
|
outcome = Tools::DelegateRelay.call(report.child_id, question, relay: relay, state_dir: @state_dir)
|
|
244
242
|
return hold_back(report) if outcome.stopped
|
|
@@ -286,7 +284,7 @@ module Samagotchi
|
|
|
286
284
|
def release_report(report)
|
|
287
285
|
@taken.delete(report.child_id)
|
|
288
286
|
Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
|
|
289
|
-
cursor.report_claim&.
|
|
287
|
+
cursor.report_claim&.id == report.claim_id ? cursor.without_claim : cursor
|
|
290
288
|
end
|
|
291
289
|
nil
|
|
292
290
|
end
|
|
@@ -330,11 +328,11 @@ module Samagotchi
|
|
|
330
328
|
timeout: 0, turn: before.turn)
|
|
331
329
|
return nil unless reportable?(wait)
|
|
332
330
|
|
|
333
|
-
claim_id = before.report_claim&.
|
|
334
|
-
after = before.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
|
|
335
|
-
|
|
331
|
+
claim_id = before.report_claim&.id || SecureRandom.hex(16)
|
|
332
|
+
after = before.without_claim.with(reply_file: wait.status == :done ? wait.file : before.reply_file,
|
|
333
|
+
turn: wait.status == :waiting_for_answer ? before.turn : nil)
|
|
336
334
|
.with_baseline(ReplyWait.baseline_of(wait.session))
|
|
337
|
-
claim =
|
|
335
|
+
claim = Tools::ReportClaim.new(id: claim_id, file: wait.file, cursor: after)
|
|
338
336
|
return nil unless Tools::DelegateCursors.claim_report(@session_id, child_id, expected: before, claim: claim,
|
|
339
337
|
state_dir: @state_dir)
|
|
340
338
|
|
data/lib/samagotchi/client_id.rb
CHANGED
|
@@ -46,8 +46,26 @@ module Samagotchi
|
|
|
46
46
|
# (#interactive?): a web tab, an attached chi TUI.
|
|
47
47
|
INTERACTIVE_PREFIXES = [WEB_PREFIX, TUI_PREFIX].freeze
|
|
48
48
|
|
|
49
|
+
# A turn's origin by its sender (#origin, LastTurn#origin): the ids chi
|
|
50
|
+
# runs turns for on its own, by prefix; system:reminder is "reminder",
|
|
51
|
+
# anything else "client".
|
|
52
|
+
ORIGIN_PREFIXES = { DELEGATE_PREFIX => "delegate", CHILD_PREFIX => "delegate_report",
|
|
53
|
+
CONTEXT_PREFIX => "context", TASK_PREFIX => "task" }.freeze
|
|
54
|
+
|
|
49
55
|
module_function
|
|
50
56
|
|
|
57
|
+
# What sent a turn: "delegate" (a delegating parent), "delegate_report"
|
|
58
|
+
# (a child's report), "context" (a context wake), "task" (a task finish
|
|
59
|
+
# wake), "reminder", else "client" (a person, chi send, a plugin, nil).
|
|
60
|
+
# @return [String]
|
|
61
|
+
def origin(client_id)
|
|
62
|
+
id = client_id.to_s
|
|
63
|
+
return "reminder" if id == REMINDER
|
|
64
|
+
|
|
65
|
+
ORIGIN_PREFIXES.each { |prefix, origin| return origin if id.start_with?(prefix) }
|
|
66
|
+
"client"
|
|
67
|
+
end
|
|
68
|
+
|
|
51
69
|
# Whether +client_id+ is a web tab or a chi TUI, which see a command's
|
|
52
70
|
# reply and can send the line it asks for (CacheConfirm); chi send, a
|
|
53
71
|
# delegate, a plugin, chi itself and nil are not.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Config
|
|
5
|
+
# Tells a worker or chi web when config.yml changes a setting it only
|
|
6
|
+
# reads at its start (Config::Entry#lifecycle: worker_start or web_start,
|
|
7
|
+
# and process_start): "server.port changed — applies after a worker
|
|
8
|
+
# restart". ConfigFile.watch_restart_settings! adds a Watcher;
|
|
9
|
+
# ConfigFile.read_yaml reports each new read of the file (#notify) or a
|
|
10
|
+
# file that doesn't parse (#unreadable), which tells nothing and warns
|
|
11
|
+
# once, instead of every start-only key reading as changed.
|
|
12
|
+
#
|
|
13
|
+
# Thread-safe: the watchers list sits behind one mutex; callbacks run
|
|
14
|
+
# outside it.
|
|
15
|
+
module RestartWatch
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@watchers = []
|
|
18
|
+
|
|
19
|
+
# One process's start-only settings and their start values.
|
|
20
|
+
class Watcher
|
|
21
|
+
LIFECYCLES = {
|
|
22
|
+
worker: %i[worker_start process_start],
|
|
23
|
+
web: %i[web_start process_start]
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :path
|
|
27
|
+
|
|
28
|
+
# @param data [Hash, nil] config.yml as the process read it at start
|
|
29
|
+
# @param file_key [Array, nil] that read's [mtime, size]
|
|
30
|
+
def initialize(process:, path:, data:, file_key:, on_change:)
|
|
31
|
+
lifecycles = LIFECYCLES.fetch(process) { raise ArgumentError, "unknown config process #{process.inspect}" }
|
|
32
|
+
raise ArgumentError, "a config change callback is required" unless on_change
|
|
33
|
+
|
|
34
|
+
@process = process
|
|
35
|
+
@path = path
|
|
36
|
+
@entries = Config::ENTRIES.select { |entry| entry.config_exposed? && lifecycles.include?(entry.lifecycle) }
|
|
37
|
+
@values = @entries.to_h { |entry| [entry.key, Config.resolve(entry.key, file_data: data, env: {})] }
|
|
38
|
+
@noticed = {}
|
|
39
|
+
@file_key = file_key
|
|
40
|
+
@on_change = on_change
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# [callback, message] for each start-only setting the file read now
|
|
44
|
+
# (+data+, under +file_key+) changes: once per key per new value,
|
|
45
|
+
# none for a value back at its start value. Empty for a read already
|
|
46
|
+
# seen. Caller holds RestartWatch's mutex.
|
|
47
|
+
def changes(file_key, data)
|
|
48
|
+
return [] unless seen!(file_key)
|
|
49
|
+
|
|
50
|
+
@entries.filter_map do |entry|
|
|
51
|
+
value = Config.resolve(entry.key, file_data: data, env: {})
|
|
52
|
+
if @values[entry.key] == value
|
|
53
|
+
@noticed.delete(entry.key)
|
|
54
|
+
next
|
|
55
|
+
end
|
|
56
|
+
# Already told for this value: a later edit of another key.
|
|
57
|
+
next if @noticed.key?(entry.key) && @noticed[entry.key] == value
|
|
58
|
+
|
|
59
|
+
@noticed[entry.key] = value
|
|
60
|
+
[@on_change, "#{entry.key} changed — applies after #{restart}"]
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Notes +file_key+ as read; false when it already was. Caller holds
|
|
65
|
+
# RestartWatch's mutex.
|
|
66
|
+
def seen!(file_key)
|
|
67
|
+
return false if @file_key == file_key
|
|
68
|
+
|
|
69
|
+
@file_key = file_key
|
|
70
|
+
true
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def restart = @process == :worker ? "a worker restart" : "restarting chi web"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
module_function
|
|
79
|
+
|
|
80
|
+
# Adds a Watcher for +process+ (:worker or :web).
|
|
81
|
+
# @return [Proc] unsubscribes
|
|
82
|
+
def watch(process:, path:, data:, file_key:, &on_change)
|
|
83
|
+
watcher = Watcher.new(process: process, path: path, data: data, file_key: file_key, on_change: on_change)
|
|
84
|
+
@mutex.synchronize { @watchers << watcher }
|
|
85
|
+
-> { @mutex.synchronize { @watchers.delete(watcher) } }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Tells each watcher of +path+ which of its start-only settings the file
|
|
89
|
+
# read now (+data+, under +file_key+; nil for a missing file) changes.
|
|
90
|
+
def notify(path, file_key, data)
|
|
91
|
+
notices = @mutex.synchronize do
|
|
92
|
+
@watchers.select { |watcher| watcher.path == path }.flat_map { |watcher| watcher.changes(file_key, data) }
|
|
93
|
+
end
|
|
94
|
+
notices.each { |callback, message| callback.call(message) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# The file at +path+ (under +file_key+) doesn't parse as a mapping
|
|
98
|
+
# (+problem+ says why): no watcher is told anything (its settings stay
|
|
99
|
+
# compared with the next read that parses), and a process watching it
|
|
100
|
+
# warns once per problem.
|
|
101
|
+
def unreadable(path, file_key, problem)
|
|
102
|
+
watched = @mutex.synchronize do
|
|
103
|
+
@watchers.select { |watcher| watcher.path == path }.each { |watcher| watcher.seen!(file_key) }.any?
|
|
104
|
+
end
|
|
105
|
+
return unless watched
|
|
106
|
+
|
|
107
|
+
ConfigFile.warn_once("config: #{path} doesn't parse (#{problem}); " \
|
|
108
|
+
"start-only settings are checked again when it does")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative "model_ref"
|
|
|
10
10
|
require_relative "paths"
|
|
11
11
|
require_relative "llm_context_strategy"
|
|
12
12
|
require_relative "host_model"
|
|
13
|
+
require_relative "config/restart_watch"
|
|
13
14
|
|
|
14
15
|
module Samagotchi
|
|
15
16
|
# Parses the thinking: levels of host and model entries (it needs Config).
|
|
@@ -556,10 +557,6 @@ module Samagotchi
|
|
|
556
557
|
resolve_with_origin(entry.key, file_data: file_data, env: ENV, cli_overrides: cli_overrides)
|
|
557
558
|
end
|
|
558
559
|
|
|
559
|
-
def set_cli_overrides(overrides)
|
|
560
|
-
reload!(cli_overrides: overrides)
|
|
561
|
-
end
|
|
562
|
-
|
|
563
560
|
# Problems with the keys of a parsed config.yml, one message per key the
|
|
564
561
|
# code doesn't read (with a "did you mean" when a known key is close).
|
|
565
562
|
# Every config-exposed entry in ENTRIES is known as written; names under
|
|
@@ -750,7 +747,7 @@ module Samagotchi
|
|
|
750
747
|
cache = @yaml_cache ||= {}
|
|
751
748
|
unless File.file?(path)
|
|
752
749
|
cache.delete(path)
|
|
753
|
-
|
|
750
|
+
Config::RestartWatch.notify(path, nil, nil)
|
|
754
751
|
return nil
|
|
755
752
|
end
|
|
756
753
|
stat = File.stat(path)
|
|
@@ -758,75 +755,37 @@ module Samagotchi
|
|
|
758
755
|
hit = cache[path]
|
|
759
756
|
return hit[1] if hit && hit[0] == key
|
|
760
757
|
|
|
758
|
+
problem = nil
|
|
761
759
|
data = begin
|
|
762
760
|
YAML.safe_load_file(path, permitted_classes: [], aliases: false)
|
|
763
|
-
rescue StandardError
|
|
761
|
+
rescue StandardError => e
|
|
762
|
+
problem = "#{e.class}: #{e.message.lines.first.to_s.strip[0, 200]}"
|
|
764
763
|
nil
|
|
765
764
|
end
|
|
765
|
+
problem ||= "its top level isn't a mapping" unless data.nil? || data.is_a?(Hash)
|
|
766
766
|
data = nil unless data.is_a?(Hash)
|
|
767
767
|
cache[path] = [key, data]
|
|
768
|
-
|
|
768
|
+
if problem
|
|
769
|
+
Config::RestartWatch.unreadable(path, key, problem)
|
|
770
|
+
else
|
|
771
|
+
Config::RestartWatch.notify(path, key, data)
|
|
772
|
+
end
|
|
769
773
|
data
|
|
770
774
|
end
|
|
771
775
|
|
|
772
776
|
# Calls +on_change+ with one line ("server.port changed — applies after a
|
|
773
777
|
# worker restart") when config.yml changes a setting this process only
|
|
774
|
-
# reads at its start (Config::
|
|
775
|
-
#
|
|
776
|
-
#
|
|
777
|
-
#
|
|
778
|
+
# reads at its start (Config::RestartWatch). Checked when the file is
|
|
779
|
+
# read again (#read_yaml), once per key per new value: none at start,
|
|
780
|
+
# none for a value back at its start value, none while the file doesn't
|
|
781
|
+
# parse (that warns once).
|
|
778
782
|
# @param process [Symbol] :worker or :web
|
|
779
783
|
# @return [Proc] unsubscribes
|
|
780
|
-
def watch_restart_settings!(process:, env: ENV, &
|
|
781
|
-
lifecycles = case process
|
|
782
|
-
when :worker then %i[worker_start process_start]
|
|
783
|
-
when :web then %i[web_start process_start]
|
|
784
|
-
else raise ArgumentError, "unknown config process #{process.inspect}"
|
|
785
|
-
end
|
|
786
|
-
raise ArgumentError, "a config change callback is required" unless on_change
|
|
787
|
-
|
|
784
|
+
def watch_restart_settings!(process:, env: ENV, &)
|
|
788
785
|
path = global_path(env: env)
|
|
789
786
|
data = read_yaml(env: env, path: path)
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
path: path, process: process, entries: entries,
|
|
793
|
-
values: entries.to_h { |entry| [entry.key, Config.resolve(entry.key, file_data: data, env: {})] },
|
|
794
|
-
noticed: {},
|
|
795
|
-
file_key: File.file?(path) ? [File.stat(path).mtime.to_r, File.size(path)] : nil,
|
|
796
|
-
on_change: on_change
|
|
797
|
-
}
|
|
798
|
-
restart_watchers_mutex.synchronize { (@restart_watchers ||= []) << watcher }
|
|
799
|
-
-> { restart_watchers_mutex.synchronize { @restart_watchers&.delete(watcher) } }
|
|
800
|
-
end
|
|
801
|
-
|
|
802
|
-
# Tells each watcher of +path+ which of its start-only settings the file
|
|
803
|
-
# read now (+data+, under +file_key+) changes.
|
|
804
|
-
def notify_restart_settings(path, file_key, data)
|
|
805
|
-
notices = restart_watchers_mutex.synchronize do
|
|
806
|
-
Array(@restart_watchers).flat_map do |watcher|
|
|
807
|
-
next [] unless watcher[:path] == path && watcher[:file_key] != file_key
|
|
808
|
-
|
|
809
|
-
watcher[:file_key] = file_key
|
|
810
|
-
watcher[:entries].filter_map do |entry|
|
|
811
|
-
value = Config.resolve(entry.key, file_data: data, env: {})
|
|
812
|
-
if watcher[:values][entry.key] == value
|
|
813
|
-
watcher[:noticed].delete(entry.key)
|
|
814
|
-
next
|
|
815
|
-
end
|
|
816
|
-
# Already told for this value: a later edit of another key.
|
|
817
|
-
next if watcher[:noticed].key?(entry.key) && watcher[:noticed][entry.key] == value
|
|
818
|
-
|
|
819
|
-
watcher[:noticed][entry.key] = value
|
|
820
|
-
restart = watcher[:process] == :worker ? "a worker restart" : "restarting chi web"
|
|
821
|
-
[watcher[:on_change], "#{entry.key} changed — applies after #{restart}"]
|
|
822
|
-
end
|
|
823
|
-
end
|
|
824
|
-
end
|
|
825
|
-
notices.each { |callback, message| callback.call(message) }
|
|
826
|
-
end
|
|
827
|
-
|
|
828
|
-
def restart_watchers_mutex
|
|
829
|
-
@restart_watchers_mutex ||= Mutex.new
|
|
787
|
+
file_key = File.file?(path) ? [File.stat(path).mtime.to_r, File.size(path)] : nil
|
|
788
|
+
Config::RestartWatch.watch(process: process, path: path, data: data, file_key: file_key, &)
|
|
830
789
|
end
|
|
831
790
|
|
|
832
791
|
# Print +message+ to stderr unless this process already did. The config
|
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1402,14 +1402,7 @@ module Samagotchi
|
|
|
1402
1402
|
# answer the continue offer says so (ReplyWait, ParentReport "limit")
|
|
1403
1403
|
# @param event [Hash] the turn's end event (#stop_facts)
|
|
1404
1404
|
def record_last_turn(session, outcome, seconds, origin, event, limit: nil)
|
|
1405
|
-
|
|
1406
|
-
source = if client_id.start_with?(ClientId::DELEGATE_PREFIX) then "delegate"
|
|
1407
|
-
elsif client_id.start_with?(ClientId::CHILD_PREFIX) then "delegate_report"
|
|
1408
|
-
elsif client_id.start_with?(ClientId::CONTEXT_PREFIX) then "context"
|
|
1409
|
-
elsif client_id.start_with?(ClientId::TASK_PREFIX) then "task"
|
|
1410
|
-
elsif client_id == ClientId::REMINDER then "reminder"
|
|
1411
|
-
else "client"
|
|
1412
|
-
end
|
|
1405
|
+
source = ClientId.origin(origin.is_a?(Hash) ? origin[:client_id] : nil)
|
|
1413
1406
|
enqueued_id = origin.is_a?(Hash) ? origin[:enqueued_id] : nil
|
|
1414
1407
|
session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
|
|
1415
1408
|
origin: source, exhausted: limit ? true : nil, limit: limit,
|
|
@@ -1523,10 +1516,9 @@ module Samagotchi
|
|
|
1523
1516
|
def guardrail_notify(message) = @hook_routes.guardrail_notify(message)
|
|
1524
1517
|
private :guardrail_notify
|
|
1525
1518
|
|
|
1519
|
+
# @param question [HookQuestion]
|
|
1526
1520
|
# @return [Hash, nil] {selected:, freeform:, selected_indices:}
|
|
1527
|
-
def hook_ask_user(question
|
|
1528
|
-
@hook_routes.ask_user(question, options, header, allow_freeform, hook, effects: effects)
|
|
1529
|
-
end
|
|
1521
|
+
def hook_ask_user(question) = @hook_routes.ask_user(question)
|
|
1530
1522
|
private :hook_ask_user
|
|
1531
1523
|
|
|
1532
1524
|
# @return [Boolean] true when a running turn was cancelled now
|
|
@@ -2569,9 +2561,7 @@ module Samagotchi
|
|
|
2569
2561
|
messages: -> { plugin_messages },
|
|
2570
2562
|
messages_partial: -> { turn_running? && @running_turn_messages.nil? },
|
|
2571
2563
|
notify: ->(text, level, label, fallback_for: nil) { hook_notify(text, level, label, fallback_for: fallback_for) },
|
|
2572
|
-
ask_user:
|
|
2573
|
-
hook_ask_user(question, options, header, allow_freeform, hook, effects: effects)
|
|
2574
|
-
},
|
|
2564
|
+
ask_user: ->(question) { hook_ask_user(question) },
|
|
2575
2565
|
# An init task's is its own (a Ctrl-C ends a turn, not the task).
|
|
2576
2566
|
cancelled: lambda {
|
|
2577
2567
|
(task = current_init_task) ? task.cancelled? : active_cancel_controller&.cancelled?
|
|
@@ -32,17 +32,30 @@ module Samagotchi
|
|
|
32
32
|
|
|
33
33
|
# Where command_word's word is in +words+ (nil when there is none).
|
|
34
34
|
def command_index(words)
|
|
35
|
-
i
|
|
35
|
+
each_argument_index(words).find { |i| !assignment?(words[i]) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Each index of +words+ from +from+ on that is no redirection and no
|
|
39
|
+
# spaced redirection's target (the "/tmp/x" of "> /tmp/x"); an
|
|
40
|
+
# Enumerator without a block.
|
|
41
|
+
def each_argument_index(words, from = 0)
|
|
42
|
+
return enum_for(:each_argument_index, words, from) unless block_given?
|
|
43
|
+
|
|
44
|
+
i = from
|
|
36
45
|
while i < words.size
|
|
37
46
|
word = words[i]
|
|
38
47
|
if word.match?(REDIRECT)
|
|
39
|
-
i +=
|
|
40
|
-
|
|
41
|
-
return i
|
|
48
|
+
i += word.match?(/\A\d*[<>]{1,2}\z/) ? 2 : 1
|
|
49
|
+
next
|
|
42
50
|
end
|
|
51
|
+
yield i
|
|
43
52
|
i += 1
|
|
44
53
|
end
|
|
45
|
-
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Whether a word is a NAME=value assignment.
|
|
57
|
+
def assignment?(word)
|
|
58
|
+
word.match?(/\A[A-Za-z_]\w*=/)
|
|
46
59
|
end
|
|
47
60
|
|
|
48
61
|
# Whether a cat or tee command sends its output to a file (a file
|
|
@@ -62,8 +75,21 @@ module Samagotchi
|
|
|
62
75
|
# ">& -". A >& with any other target does write one: bash reads
|
|
63
76
|
# ">& file" and ">&file" as "&>file".
|
|
64
77
|
def stdout_file?(words)
|
|
78
|
+
redirects_to_file?(words, /\A1?(>>?)(&?)(.*)\z/m)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Whether one of +words+ sends any fd's output to a file (3>run.sh,
|
|
82
|
+
# 2>>log, {fd}>run.sh, 3<>run.sh as well as stdout_file?'s forms),
|
|
83
|
+
# /dev/null and fd dups left out as there.
|
|
84
|
+
def output_file?(words)
|
|
85
|
+
redirects_to_file?(words, /\A(?:\d*|\{\w+\})(>>?|<>)(&?)(.*)\z/m)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# stdout_file? and output_file?: a word matching +pattern+ (its
|
|
89
|
+
# captures: the operator, an &, the target) that writes a file.
|
|
90
|
+
def redirects_to_file?(words, pattern)
|
|
65
91
|
words.each_with_index do |word, i|
|
|
66
|
-
match = word.match(
|
|
92
|
+
match = word.match(pattern)
|
|
67
93
|
next unless match
|
|
68
94
|
|
|
69
95
|
amp = match[2]
|
|
@@ -84,18 +110,7 @@ module Samagotchi
|
|
|
84
110
|
verb = command_index(words)
|
|
85
111
|
return false unless verb
|
|
86
112
|
|
|
87
|
-
|
|
88
|
-
while i < words.size
|
|
89
|
-
word = words[i]
|
|
90
|
-
if word.match?(REDIRECT)
|
|
91
|
-
i += word.match?(/\A\d*[<>]{1,2}\z/) ? 2 : 1
|
|
92
|
-
next
|
|
93
|
-
end
|
|
94
|
-
return true if filename_word?(word)
|
|
95
|
-
|
|
96
|
-
i += 1
|
|
97
|
-
end
|
|
98
|
-
false
|
|
113
|
+
each_argument_index(words, verb + 1).any? { |i| filename_word?(words[i]) }
|
|
99
114
|
end
|
|
100
115
|
|
|
101
116
|
# Whether a tee argument names a file: not an option, a heredoc's tag,
|
|
@@ -139,18 +154,7 @@ module Samagotchi
|
|
|
139
154
|
# their spaced targets), the <<TAG words and the heredoc bodies left
|
|
140
155
|
# out.
|
|
141
156
|
def arguments(words)
|
|
142
|
-
|
|
143
|
-
i = 0
|
|
144
|
-
while i < words.size
|
|
145
|
-
word = words[i]
|
|
146
|
-
if word.match?(REDIRECT)
|
|
147
|
-
i += word.match?(/\A\d*[<>]{1,2}\z/) ? 2 : 1
|
|
148
|
-
next
|
|
149
|
-
end
|
|
150
|
-
args << word unless word.match?(/\A[A-Za-z_]\w*=/) || heredoc_word?(word)
|
|
151
|
-
i += 1
|
|
152
|
-
end
|
|
153
|
-
args
|
|
157
|
+
each_argument_index(words).map { |i| words[i] }.reject { |word| assignment?(word) || heredoc_word?(word) }
|
|
154
158
|
end
|
|
155
159
|
|
|
156
160
|
# Whether a word is a heredoc's <<TAG or one of its bodies.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module Guardrails
|
|
5
|
+
# The parent-answers level words and their one reading (#parse_level),
|
|
6
|
+
# apart from the rest of ParentPolicy so Session (its parent_answers
|
|
7
|
+
# setter) reads them without loading the guardrails.
|
|
8
|
+
module ParentPolicy
|
|
9
|
+
HARD = "hard"
|
|
10
|
+
STANDARD = "standard"
|
|
11
|
+
SOFT = "soft"
|
|
12
|
+
# Strictest first: a session's own level may only move left of
|
|
13
|
+
# config.yml's.
|
|
14
|
+
ORDER = [HARD, STANDARD, SOFT].freeze
|
|
15
|
+
# A session value that means "none of its own" (config's).
|
|
16
|
+
UNSET = "default"
|
|
17
|
+
|
|
18
|
+
# A level word as written anywhere (a session file, /parent-answers,
|
|
19
|
+
# --parent-answers), stripped and case-blind.
|
|
20
|
+
# @param word [String, Symbol, nil]
|
|
21
|
+
# @return [String, Symbol] the level, :unset (nil, "" or "default":
|
|
22
|
+
# config's) or :invalid
|
|
23
|
+
def self.parse_level(word)
|
|
24
|
+
text = word.to_s.strip.downcase
|
|
25
|
+
return :unset if text.empty? || text == UNSET
|
|
26
|
+
|
|
27
|
+
ORDER.include?(text) ? text : :invalid
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../config"
|
|
4
4
|
require_relative "../pending_question"
|
|
5
5
|
require_relative "approval"
|
|
6
|
+
require_relative "parent_levels"
|
|
6
7
|
require_relative "parent_approvals"
|
|
7
8
|
|
|
8
9
|
module Samagotchi
|
|
@@ -49,9 +50,8 @@ module Samagotchi
|
|
|
49
50
|
KEY = "guardrails.parent_answers"
|
|
50
51
|
# A session's own value (Session#parent_answers): it may only tighten KEY.
|
|
51
52
|
SESSION_KEY = "parent_answers"
|
|
52
|
-
HARD
|
|
53
|
-
|
|
54
|
-
SOFT = "soft"
|
|
53
|
+
# The level words (HARD, STANDARD, SOFT, ORDER, UNSET) and
|
|
54
|
+
# #parse_level: parent_levels.rb.
|
|
55
55
|
|
|
56
56
|
# A strategy: its name, the effects a parent may pick under it, and
|
|
57
57
|
# where it comes from: :config (config.yml's) or :session (a
|
|
@@ -83,12 +83,6 @@ module Samagotchi
|
|
|
83
83
|
# @return [Strategy]
|
|
84
84
|
def strategy(name) = STRATEGIES.fetch(name.to_s, STRATEGIES[HARD])
|
|
85
85
|
|
|
86
|
-
# Strictest first: a session's own level may only move left of
|
|
87
|
-
# config.yml's.
|
|
88
|
-
ORDER = [HARD, STANDARD, SOFT].freeze
|
|
89
|
-
# A session value that means "none of its own" (config's).
|
|
90
|
-
UNSET = "default"
|
|
91
|
-
|
|
92
86
|
# The stricter of two strategies, by name; a tie keeps +a+.
|
|
93
87
|
# @return [Strategy]
|
|
94
88
|
def stricter(a, b) = ORDER.index(b.name) < ORDER.index(a.name) ? b : a
|
|
@@ -99,18 +93,16 @@ module Samagotchi
|
|
|
99
93
|
# is read on every parent answer).
|
|
100
94
|
# @return [String, nil]
|
|
101
95
|
def session_level(value)
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
96
|
+
case (level = parse_level(value))
|
|
97
|
+
when :unset then nil
|
|
98
|
+
when :invalid then HARD
|
|
99
|
+
else level
|
|
100
|
+
end
|
|
106
101
|
end
|
|
107
102
|
|
|
108
103
|
# Whether a session's own value is set but not a level (a hand-edited
|
|
109
104
|
# file: read as hard); the worker says so once when it loads.
|
|
110
|
-
def unknown_level?(value)
|
|
111
|
-
word = value.to_s.strip.downcase
|
|
112
|
-
!word.empty? && word != UNSET && !STRATEGIES.key?(word)
|
|
113
|
-
end
|
|
105
|
+
def unknown_level?(value) = parse_level(value) == :invalid
|
|
114
106
|
|
|
115
107
|
# What a parent may answer on a session whose own value is
|
|
116
108
|
# +session_value+: config.yml's (+config+) unless the session's is
|
|
@@ -150,9 +142,6 @@ module Samagotchi
|
|
|
150
142
|
level.nil? ? ORDER.size : ORDER.index(level)
|
|
151
143
|
end
|
|
152
144
|
|
|
153
|
-
# @return [Array<String>] the effects +strategy+ lets a parent pick
|
|
154
|
-
def allowed_effects(strategy) = strategy.allowed
|
|
155
|
-
|
|
156
145
|
# Each option's effect, in option order. Tagged questions carry them;
|
|
157
146
|
# an untagged one (asked by an older worker, a relayed card built
|
|
158
147
|
# from an older child's question) gets them derived as before:
|
|
@@ -95,6 +95,7 @@ module Samagotchi
|
|
|
95
95
|
|
|
96
96
|
bodies = lexer.heredoc_spans.map { |doc| doc[:span] }
|
|
97
97
|
cuts = []
|
|
98
|
+
redirected = false
|
|
98
99
|
each_command(tokens) do |indices|
|
|
99
100
|
words = indices.map { |i| tokens[i][1] }
|
|
100
101
|
indices.each_with_index do |i, position|
|
|
@@ -103,12 +104,13 @@ module Samagotchi
|
|
|
103
104
|
|
|
104
105
|
span = lexer.spans[i]
|
|
105
106
|
cut = if bodies.include?(span)
|
|
106
|
-
body_cut(text, span, words) if value == HEREDOC
|
|
107
|
+
body_cut(text, span, words, redirected) if value == HEREDOC
|
|
107
108
|
elsif value == SUBST
|
|
108
109
|
substitution_cut(text, tokens, i, span, words, position)
|
|
109
110
|
end
|
|
110
111
|
cuts << cut if cut
|
|
111
112
|
end
|
|
113
|
+
redirected ||= exec_redirect?(tokens, indices, words)
|
|
112
114
|
end
|
|
113
115
|
cuts.sort_by { |span, _| -span.begin }.each_with_object(+text) do |(span, replacement), out|
|
|
114
116
|
out[span] = replacement
|
|
@@ -134,25 +136,44 @@ module Samagotchi
|
|
|
134
136
|
|
|
135
137
|
# [span, what it becomes] for a data heredoc's body at +span+ (its
|
|
136
138
|
# command's +words+), or nil when the body stays in the code text: a
|
|
137
|
-
# cat or tee writing a file may be run later (D1)
|
|
138
|
-
|
|
139
|
-
|
|
139
|
+
# cat or tee writing a file may be run later (D1), and so may one
|
|
140
|
+
# printing after an earlier exec sent the shell's output to a file
|
|
141
|
+
# (+redirected+).
|
|
142
|
+
def self.body_cut(text, span, words, redirected)
|
|
143
|
+
return nil unless cut_body?(words, redirected)
|
|
140
144
|
|
|
141
145
|
[span, terminator_line(text[span])]
|
|
142
146
|
end
|
|
143
147
|
private_class_method :body_cut
|
|
144
148
|
|
|
145
149
|
# Whether a body a data reader reads leaves the code text: a cat or tee
|
|
146
|
-
# sending it to a file keeps it (D1)
|
|
150
|
+
# sending it to a file keeps it (D1), as does any cat or tee once an
|
|
151
|
+
# exec redirected the shell's output (+redirected+: its stdout, or the
|
|
152
|
+
# fd a >&3 names, may be a file); a body any other command reads as
|
|
147
153
|
# data is one the lexer's data_sink? approved.
|
|
148
|
-
def self.cut_body?(words)
|
|
154
|
+
def self.cut_body?(words, redirected)
|
|
149
155
|
verb = DataReaders.command_word(words)
|
|
150
156
|
return true unless DataReaders::SINKS.include?(File.basename(verb.to_s))
|
|
151
157
|
|
|
152
|
-
!DataReaders.writes_file?(words)
|
|
158
|
+
!redirected && !DataReaders.writes_file?(words)
|
|
153
159
|
end
|
|
154
160
|
private_class_method :cut_body?
|
|
155
161
|
|
|
162
|
+
# Whether the simple command at +indices+ may be an exec that redirects
|
|
163
|
+
# the shell's own output to a file (exec >run.sh, exec 3>run.sh,
|
|
164
|
+
# exec {fd}>run.sh, exec 3<>run.sh): what a later cat or tee prints may
|
|
165
|
+
# land there. Any exec word counts, not only the command word ({ exec
|
|
166
|
+
# …; }, then exec …, command exec …, a function's body), and so does an
|
|
167
|
+
# exec backgrounded (exec &>run.sh, which the lexer reads as "exec &"
|
|
168
|
+
# and ">run.sh"): the conservative reading.
|
|
169
|
+
def self.exec_redirect?(tokens, indices, words)
|
|
170
|
+
return false unless words.include?("exec")
|
|
171
|
+
|
|
172
|
+
following = tokens[indices.last + 1]
|
|
173
|
+
DataReaders.output_file?(words) || following == [:op, "&"]
|
|
174
|
+
end
|
|
175
|
+
private_class_method :exec_redirect?
|
|
176
|
+
|
|
156
177
|
# [span, what it becomes] for the body of a "$(cat <<'EOF' … EOF)" given
|
|
157
178
|
# as a message (-m, --body, …) of a data reader (D2), or nil: eval,
|
|
158
179
|
# bash -c, a command piped on and a body that runs stay.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
# A hook's or a plugin's question (event[:ask_user], ctx.ask_user) as one
|
|
5
|
+
# value on its way to the question flow: built at the plugin-facing edge
|
|
6
|
+
# (Hooks::Registry's event helper, Plugin::Context#ask_user), passed
|
|
7
|
+
# through the hook runtime and the plugin host (Hooks::Runtime#ask_user,
|
|
8
|
+
# Plugin::Host#ask_user), Engine#hook_ask_user and HookRoutes#ask_user to
|
|
9
|
+
# QuestionDesk#ask_for_hook. The bundle test kit records it as asked
|
|
10
|
+
# (Testing::Recorder#questions).
|
|
11
|
+
#
|
|
12
|
+
# question, options, header, allow_freeform: as the hook gave them
|
|
13
|
+
# (QuestionDesk checks the options); hook: the asking hook's or plugin's
|
|
14
|
+
# label; effects: what an option does for a parent agent's answer, by its
|
|
15
|
+
# label (label => effect, as the hook gave it; nil for none). QuestionDesk
|
|
16
|
+
# turns it into the pending question's per-option tags
|
|
17
|
+
# (Guardrails::ParentPolicy.option_tags, PendingQuestion#effects).
|
|
18
|
+
HookQuestion = Data.define(:question, :options, :header, :allow_freeform, :hook, :effects) do
|
|
19
|
+
def initialize(effects: nil, **) = super
|
|
20
|
+
end
|
|
21
|
+
end
|