samagotchi 0.15.0 → 0.17.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 +135 -1
- data/README.md +2 -0
- data/docs/architecture.md +23 -0
- data/docs/cli.md +7 -4
- data/docs/configuration.md +7 -5
- data/docs/guardrails.md +16 -2
- data/docs/internals/background-tasks.md +1 -0
- data/docs/memory.md +39 -0
- data/docs/plugins.md +2 -0
- data/docs/sessions.md +10 -4
- data/docs/sub-agent.md +16 -3
- data/docs/testing.md +47 -0
- data/lib/samagotchi/answer_display.rb +6 -2
- data/lib/samagotchi/bridge/card_store.rb +16 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +26 -5
- data/lib/samagotchi/bridge.rb +122 -7
- data/lib/samagotchi/bridge_client.rb +33 -6
- data/lib/samagotchi/bundle_command.rb +13 -2
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
- data/lib/samagotchi/bundles/system/self_map.md +3 -1
- data/lib/samagotchi/empty_answer_retry.rb +2 -1
- data/lib/samagotchi/engine.rb +82 -18
- data/lib/samagotchi/kernel_loop.rb +16 -5
- data/lib/samagotchi/llm/chat_loop.rb +14 -8
- data/lib/samagotchi/llm/model_result.rb +12 -7
- data/lib/samagotchi/log_subscriber.rb +1 -0
- data/lib/samagotchi/memory_bundle/builder.rb +22 -0
- data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
- data/lib/samagotchi/memory_bundle/installer.rb +60 -21
- data/lib/samagotchi/memory_bundle/profile.rb +9 -4
- data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
- data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
- data/lib/samagotchi/memory_bundle/trash.rb +57 -0
- data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
- data/lib/samagotchi/parent_report.rb +8 -0
- data/lib/samagotchi/plugin/context.rb +14 -2
- data/lib/samagotchi/question_desk.rb +97 -18
- data/lib/samagotchi/relay.rb +88 -0
- data/lib/samagotchi/relay_desk.rb +73 -0
- data/lib/samagotchi/relay_verifier.rb +113 -0
- data/lib/samagotchi/relay_watcher.rb +46 -0
- data/lib/samagotchi/reply_wait.rb +10 -1
- data/lib/samagotchi/self_report.rb +49 -3
- data/lib/samagotchi/send_command.rb +45 -6
- data/lib/samagotchi/session.rb +15 -2
- data/lib/samagotchi/session_manager.rb +3 -2
- data/lib/samagotchi/session_metrics.rb +154 -48
- data/lib/samagotchi/sessions_command.rb +10 -4
- data/lib/samagotchi/system_prompt.rb +45 -4
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
- data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -21
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -2
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
- data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
- data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
- data/lib/samagotchi/terminal_ui.rb +24 -0
- data/lib/samagotchi/tool_declarations.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +11 -3
- data/lib/samagotchi/tools/delegate_relay.rb +220 -0
- data/lib/samagotchi/tools/delegate_wait.rb +72 -6
- data/lib/samagotchi/tools/peers.rb +7 -4
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/turn_note.rb +30 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +22 -1
- data/lib/samagotchi/web/public/app.js +60 -33
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/notify.js +22 -5
- data/lib/samagotchi/web/public/question_card.js +45 -3
- data/lib/samagotchi/web/public/question_cards.js +49 -1
- data/lib/samagotchi/web/public/sessions_list.js +8 -1
- data/lib/samagotchi/web/public/stage_model.js +8 -3
- data/lib/samagotchi/web/public/stage_view.js +20 -3
- data/lib/samagotchi/web/public/strip.js +11 -0
- data/lib/samagotchi/web/public/timing.js +37 -15
- data/lib/samagotchi/web/public/turn_events.js +22 -3
- data/lib/samagotchi/web/public/turn_view.js +34 -12
- data/lib/samagotchi/worker.rb +28 -2
- data/lib/samagotchi/worker_sidecar.rb +4 -1
- metadata +8 -1
|
@@ -30,8 +30,10 @@ module Samagotchi
|
|
|
30
30
|
def failed? = !failed.empty?
|
|
31
31
|
end
|
|
32
32
|
# removed: members uninstalled; blocked: name => why; gone: the meta
|
|
33
|
-
# itself was removed (only once every member is)
|
|
34
|
-
|
|
33
|
+
# itself was removed (only once every member is); trash: member =>
|
|
34
|
+
# [memory files, the trash dir they were moved to]; warnings: the
|
|
35
|
+
# members' ("member: line").
|
|
36
|
+
UninstallResult = Struct.new(:name, :removed, :blocked, :gone, :trash, :warnings, keyword_init: true)
|
|
35
37
|
|
|
36
38
|
module_function
|
|
37
39
|
|
|
@@ -127,10 +129,13 @@ module Samagotchi
|
|
|
127
129
|
shipped = File.join(shipped_dir, name)
|
|
128
130
|
manifest = File.file?(File.join(shipped, "manifest.yml")) ? Manifest.read(dir: shipped) : nil
|
|
129
131
|
members = recorded(data, manifest)
|
|
130
|
-
result = UninstallResult.new(name: name, removed: [], blocked: {}, gone: false)
|
|
132
|
+
result = UninstallResult.new(name: name, removed: [], blocked: {}, gone: false, trash: {}, warnings: [])
|
|
131
133
|
(members & installed_names).each do |member|
|
|
132
|
-
Uninstaller.new(name: member, force: force)
|
|
134
|
+
uninstaller = Uninstaller.new(name: member, force: force)
|
|
135
|
+
uninstaller.run
|
|
133
136
|
result.removed << member
|
|
137
|
+
result.trash[member] = [uninstaller.trashed_files, uninstaller.trash_dir] if uninstaller.trash_dir
|
|
138
|
+
result.warnings.concat(uninstaller.warnings.map { |w| "#{member}: #{w}" })
|
|
134
139
|
rescue Uninstaller::UninstallError => e
|
|
135
140
|
result.blocked[member] = e.message
|
|
136
141
|
end
|
|
@@ -84,6 +84,20 @@ module Samagotchi
|
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
+
# The other installed bundles of +scope+ whose record lists the memory
|
|
88
|
+
# file +file_key+, by name (a manifest that doesn't read is left out).
|
|
89
|
+
# A project-scoped record doesn't say which project: one from another
|
|
90
|
+
# repo counts too, which errs on keeping the file.
|
|
91
|
+
def self.claimants(file_key, scope:, except: nil, dir: bundles_dir)
|
|
92
|
+
each_installed(dir: dir).filter_map do |name, data|
|
|
93
|
+
next if name == except || data[:error]
|
|
94
|
+
next unless (data[:scope].to_s.empty? ? "system" : data[:scope].to_s) == scope.to_s
|
|
95
|
+
next unless data[:files].is_a?(Hash) && data[:files].keys.map(&:to_s).include?(file_key.to_s)
|
|
96
|
+
|
|
97
|
+
name
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
87
101
|
# The hex digest a manifest records, in either of its formats:
|
|
88
102
|
# "sha256:<hex>" (hooks, rules, the plugin) or bare <hex> (a memory
|
|
89
103
|
# file's checksum:). "" when none is recorded.
|
|
@@ -143,13 +143,15 @@ module Samagotchi
|
|
|
143
143
|
row
|
|
144
144
|
end
|
|
145
145
|
|
|
146
|
-
# The .md files whose local edits conflict with the shipped
|
|
146
|
+
# The bundle's .md files whose local edits conflict with the shipped
|
|
147
|
+
# version (a same-name file it didn't install is skipped, not merged).
|
|
147
148
|
def conflicts(row, data)
|
|
148
149
|
provenance = Provenance.new(name: row.name)
|
|
149
150
|
target_dir = Status.resolve_target_dir(row.scope)
|
|
150
|
-
return [] unless data[:files]
|
|
151
|
+
return [] unless data[:files].is_a?(Hash)
|
|
151
152
|
|
|
152
|
-
|
|
153
|
+
owned = data[:files].keys.map(&:to_s)
|
|
154
|
+
(Dir.glob(File.join(row.source_dir, "*.md")).map { |f| File.basename(f) }.sort & owned).select do |key|
|
|
153
155
|
Merger.classify(base_path: provenance.base_path(key), current_path: File.join(target_dir, key),
|
|
154
156
|
incoming_path: File.join(row.source_dir, key)) == :conflict
|
|
155
157
|
end
|
|
@@ -169,11 +169,13 @@ module Samagotchi
|
|
|
169
169
|
result(:restored, from: version, to: version)
|
|
170
170
|
end
|
|
171
171
|
|
|
172
|
-
# The files an upgrade would keep:
|
|
173
|
-
# shipped bundle (a dry run's kept).
|
|
172
|
+
# The files an upgrade would keep: installed by the bundle, edited
|
|
173
|
+
# here and changed in the shipped bundle (a dry run's kept).
|
|
174
174
|
def conflicting(gem_manifest)
|
|
175
175
|
provenance = Provenance.new(name: BUNDLE_NAME)
|
|
176
|
-
|
|
176
|
+
owned = (provenance.read || {})[:files]
|
|
177
|
+
owned = owned.is_a?(Hash) ? owned.keys.map(&:to_s) : []
|
|
178
|
+
(gem_manifest.files.keys.map(&:to_s).sort & owned).select do |key|
|
|
177
179
|
Merger.classify(base_path: provenance.base_path(key), current_path: File.join(MemoryPaths.system_dir, key),
|
|
178
180
|
incoming_path: File.join(GEM_BUNDLE_DIR, key)) == :conflict
|
|
179
181
|
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../memory_paths"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
module MemoryBundle
|
|
8
|
+
# Where a bundle operation puts the memory files it takes away
|
|
9
|
+
# (uninstall, an upgrade's dropped file): moved, never deleted, into
|
|
10
|
+
# <system memories>/.bundles/.trash/<bundle>-<YYYYmmdd-HHMMSS>/, one dir
|
|
11
|
+
# per operation (-2, -3… when one already has that name). A dot dir, so
|
|
12
|
+
# Provenance.each_installed never reads it as a bundle. Nothing prunes
|
|
13
|
+
# it: the user deletes it when sure.
|
|
14
|
+
class Trash
|
|
15
|
+
def self.root(bundles_dir: MemoryPaths.bundles_dir)
|
|
16
|
+
File.join(bundles_dir, ".trash")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The dir, once a file was moved (nil before).
|
|
20
|
+
attr_reader :dir
|
|
21
|
+
|
|
22
|
+
def initialize(name, now: Time.now, bundles_dir: MemoryPaths.bundles_dir)
|
|
23
|
+
@name = name.to_s
|
|
24
|
+
@now = now
|
|
25
|
+
@bundles_dir = bundles_dir
|
|
26
|
+
@dir = nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Moves +path+ into the trash dir (created on the first call).
|
|
30
|
+
# @return [String] the file's new path
|
|
31
|
+
def move(path)
|
|
32
|
+
dest = File.join(ensure_dir, File.basename(path))
|
|
33
|
+
FileUtils.mv(path, dest)
|
|
34
|
+
dest
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def ensure_dir
|
|
40
|
+
return @dir if @dir
|
|
41
|
+
|
|
42
|
+
base = File.join(self.class.root(bundles_dir: @bundles_dir), "#{@name}-#{@now.strftime("%Y%m%d-%H%M%S")}")
|
|
43
|
+
FileUtils.mkdir_p(File.dirname(base))
|
|
44
|
+
candidate = base
|
|
45
|
+
n = 1
|
|
46
|
+
loop do
|
|
47
|
+
Dir.mkdir(candidate)
|
|
48
|
+
break
|
|
49
|
+
rescue Errno::EEXIST
|
|
50
|
+
n += 1
|
|
51
|
+
candidate = "#{base}-#{n}"
|
|
52
|
+
end
|
|
53
|
+
@dir = candidate
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -5,13 +5,17 @@ require_relative "../memory_paths"
|
|
|
5
5
|
require_relative "provenance"
|
|
6
6
|
require_relative "index_updater"
|
|
7
7
|
require_relative "merger"
|
|
8
|
+
require_relative "trash"
|
|
8
9
|
|
|
9
10
|
module Samagotchi
|
|
10
11
|
module MemoryBundle
|
|
11
12
|
class Uninstaller
|
|
12
13
|
class UninstallError < StandardError; end
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
# removed_files: the bundle's own hooks/ and plugin/ files (deleted);
|
|
16
|
+
# trashed_files: its memory files, moved to trash_dir (Trash), nil
|
|
17
|
+
# when none was moved.
|
|
18
|
+
attr_reader :warnings, :removed_files, :trashed_files
|
|
15
19
|
|
|
16
20
|
def initialize(name:, scope: nil, force: false)
|
|
17
21
|
@name = name
|
|
@@ -19,8 +23,12 @@ module Samagotchi
|
|
|
19
23
|
@force = force
|
|
20
24
|
@warnings = []
|
|
21
25
|
@removed_files = []
|
|
26
|
+
@trashed_files = []
|
|
27
|
+
@trash = Trash.new(name)
|
|
22
28
|
end
|
|
23
29
|
|
|
30
|
+
def trash_dir = @trash.dir
|
|
31
|
+
|
|
24
32
|
def run
|
|
25
33
|
provenance = Provenance.new(name: @name)
|
|
26
34
|
data = provenance.read
|
|
@@ -40,12 +48,18 @@ module Samagotchi
|
|
|
40
48
|
return true
|
|
41
49
|
end
|
|
42
50
|
|
|
51
|
+
# A file another installed bundle lists too stays, edited or not.
|
|
52
|
+
shared = files.keys.map(&:to_s).to_h do |key|
|
|
53
|
+
[key, Provenance.claimants(key, scope: target_scope, except: @name)]
|
|
54
|
+
end.reject { |_, others| others.empty? }
|
|
55
|
+
|
|
43
56
|
blocked = []
|
|
44
57
|
files.each do |file_key, meta|
|
|
45
58
|
file_key_str = file_key.to_s
|
|
46
59
|
target_path = File.join(target_dir, file_key_str)
|
|
47
60
|
base_path = provenance.base_path(file_key_str)
|
|
48
61
|
next unless File.exist?(target_path)
|
|
62
|
+
next if shared.key?(file_key_str)
|
|
49
63
|
if !@force && File.exist?(base_path) && Merger.current_modified?(base_path, target_path)
|
|
50
64
|
blocked << file_key_str
|
|
51
65
|
@warnings << "Skipped #{file_key_str}: local edits detected (use --force to remove)"
|
|
@@ -57,15 +71,23 @@ module Samagotchi
|
|
|
57
71
|
files.each do |file_key, _meta|
|
|
58
72
|
file_key_str = file_key.to_s
|
|
59
73
|
target_path = File.join(target_dir, file_key_str)
|
|
74
|
+
if (others = shared[file_key_str])
|
|
75
|
+
next unless File.exist?(target_path)
|
|
76
|
+
|
|
77
|
+
@warnings << "Kept #{file_key_str}: bundle #{others.join(", ")} has it too"
|
|
78
|
+
retag(target_scope, target_path, file_key_str, others.first)
|
|
79
|
+
next
|
|
80
|
+
end
|
|
60
81
|
if File.exist?(target_path)
|
|
61
|
-
|
|
62
|
-
@
|
|
82
|
+
@trash.move(target_path)
|
|
83
|
+
@trashed_files << file_key_str
|
|
63
84
|
end
|
|
64
|
-
# Remove index line
|
|
85
|
+
# Remove index line (not fatal: a warning says so)
|
|
65
86
|
begin
|
|
66
87
|
IndexUpdater.remove_index(target_scope, file_key_str.delete_suffix(".md"))
|
|
67
88
|
IndexUpdater.remove_index(target_scope, file_key_str) # legacy "name.md" line
|
|
68
|
-
rescue =>
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
@warnings << "index.md: line for #{file_key_str.delete_suffix(".md")} not removed (#{e.message})"
|
|
69
91
|
end
|
|
70
92
|
end
|
|
71
93
|
|
|
@@ -97,6 +119,13 @@ module Samagotchi
|
|
|
97
119
|
|
|
98
120
|
private
|
|
99
121
|
|
|
122
|
+
# A kept shared file's index line names the bundle that still has it.
|
|
123
|
+
def retag(scope, path, file_key, bundle)
|
|
124
|
+
IndexUpdater.update_index(scope, file_key.delete_suffix(".md"), File.size(path), source: bundle)
|
|
125
|
+
rescue StandardError => e
|
|
126
|
+
@warnings << "index.md: line for #{file_key.delete_suffix(".md")} not updated (#{e.message})"
|
|
127
|
+
end
|
|
128
|
+
|
|
100
129
|
def resolve_target_dir(scope)
|
|
101
130
|
MemoryPaths.scope_dir(scope) or raise UninstallError, "invalid scope: #{scope}"
|
|
102
131
|
end
|
|
@@ -145,6 +145,13 @@ module Samagotchi
|
|
|
145
145
|
# What a parent does with an approval.
|
|
146
146
|
DENY_AND_TELL = "deny it, and tell your user"
|
|
147
147
|
|
|
148
|
+
# The other way: the user answers it in chi (the web's bell and badge
|
|
149
|
+
# say it waits), and a wait with no message waits past it.
|
|
150
|
+
def leave_open(session_id)
|
|
151
|
+
"or leave it open: tell your user it waits in chi web (session #{session_id[0, 8]}); " \
|
|
152
|
+
"chi send --wait --format json #{session_id} waits until they answer"
|
|
153
|
+
end
|
|
154
|
+
|
|
148
155
|
def approval?(question)
|
|
149
156
|
question.is_a?(Hash) && (question[:kind] || question["kind"]).to_s == "approval"
|
|
150
157
|
end
|
|
@@ -180,6 +187,7 @@ module Samagotchi
|
|
|
180
187
|
if approval?(q)
|
|
181
188
|
lines << " allowing it is up to your user: #{DENY_AND_TELL}"
|
|
182
189
|
lines << " deny: #{answer_with(session_id, q)}"
|
|
190
|
+
lines << " #{leave_open(session_id)}"
|
|
183
191
|
else
|
|
184
192
|
lines << " more than one allowed: repeat --option" if q[:multi_select]
|
|
185
193
|
lines << " free text allowed: --text" if q[:allow_freeform]
|
|
@@ -18,11 +18,14 @@ module Samagotchi
|
|
|
18
18
|
# (chat messages, timeout:, max_tokens:, cancel_controller:) and returns
|
|
19
19
|
# the answer text.
|
|
20
20
|
# +messages_partial+ says whether +messages+ leaves out a running turn;
|
|
21
|
-
# +model_name+
|
|
21
|
+
# +model_name+ (the session's resolved model ref, live after /model) and
|
|
22
|
+
# +state_dir+ are what ctx.sessions forks with; +model_key+ is the
|
|
23
|
+
# model's memory overlay key (ctx.model, ctx.model_key).
|
|
22
24
|
# +steer+ takes (text, label), +stop_turn+ and +stop_generation+
|
|
23
25
|
# (reason, label), each true when it acted on a running turn.
|
|
24
26
|
Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
|
|
25
|
-
:ask_model, :model_name, :state_dir, :scratch, :steer, :stop_turn, :stop_generation,
|
|
27
|
+
:ask_model, :model_name, :model_key, :state_dir, :scratch, :steer, :stop_turn, :stop_generation,
|
|
28
|
+
keyword_init: true)
|
|
26
29
|
|
|
27
30
|
# ctx.ask_model failed: the model couldn't be reached, timed out, or
|
|
28
31
|
# sent nothing usable. The message says why, for the user.
|
|
@@ -86,6 +89,15 @@ module Samagotchi
|
|
|
86
89
|
# @return [String, nil] the session's id (nil before the first one)
|
|
87
90
|
def session_id = @host.session_id.call
|
|
88
91
|
|
|
92
|
+
# @return [String, nil] the model the session runs on now: its
|
|
93
|
+
# resolved ref ("host:id"), right after /model too
|
|
94
|
+
def model = @host.model_name&.call
|
|
95
|
+
|
|
96
|
+
# @return [String, nil] the model's memory overlay key
|
|
97
|
+
# (`<name>.<key>.md`, ModelOverlay.key_for), as memory_write
|
|
98
|
+
# current_model_only names it
|
|
99
|
+
def model_key = @host.model_key&.call
|
|
100
|
+
|
|
89
101
|
# @return [String] the session's working directory
|
|
90
102
|
def cwd = @host.cwd.call || Dir.pwd
|
|
91
103
|
|
|
@@ -36,6 +36,9 @@ module Samagotchi
|
|
|
36
36
|
end
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Seconds between two looks of an open question's watch (#open_question).
|
|
40
|
+
WATCH_INTERVAL = 0.5
|
|
41
|
+
|
|
39
42
|
DISMISSED_NOTE = "The user dismissed the question without answering. Don't do what you asked about, " \
|
|
40
43
|
"or anything else that changes files or state. Finish your reply with what you found " \
|
|
41
44
|
"and what you would do, and wait."
|
|
@@ -73,11 +76,13 @@ module Samagotchi
|
|
|
73
76
|
# @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
|
|
74
77
|
# @return [String] normalized answer JSON
|
|
75
78
|
def request(payload)
|
|
76
|
-
result = open_question(**payload.slice(:question, :options, :header),
|
|
77
|
-
|
|
79
|
+
result = open_question({ **payload.slice(:question, :options, :header),
|
|
80
|
+
multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform] })
|
|
78
81
|
# Dismissed (the card's dismiss, Esc): an answer of its own, not a
|
|
79
82
|
# tool failure the model learns to avoid the tool from.
|
|
80
83
|
result = { dismissed: true, id: result[:id], note: DISMISSED_NOTE } if result.is_a?(Hash) && result[:error] == "no answer"
|
|
84
|
+
# Who answered is for the relay, not the model's tool result.
|
|
85
|
+
result = result.except(:by) if result.is_a?(Hash)
|
|
81
86
|
result.is_a?(String) ? result : JSON.generate(result)
|
|
82
87
|
end
|
|
83
88
|
|
|
@@ -87,9 +92,13 @@ module Samagotchi
|
|
|
87
92
|
# The fields go to pending as given (no cleaning), extra keys
|
|
88
93
|
# included, so a caller can add its own (kind:, approval:).
|
|
89
94
|
# @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
|
|
95
|
+
# @param watch [#call, nil] asked every WATCH_INTERVAL seconds while the
|
|
96
|
+
# question waits (outside the lock): a String closes the question with
|
|
97
|
+
# that reason, announced and returned as a cancel; nil keeps waiting.
|
|
98
|
+
# An answer recorded first wins. Not on the sync-handler path.
|
|
90
99
|
# @return [Hash, String] the answer {id:, selected:, freeform:, selected_indices:},
|
|
91
100
|
# or {error:, …}; a String when a sync handler returned text itself
|
|
92
|
-
def open_question(fields)
|
|
101
|
+
def open_question(fields, watch: nil)
|
|
93
102
|
id = SecureRandom.uuid
|
|
94
103
|
pending = { id: id, **fields, status: "pending", created_at: Time.now.iso8601(3) }.compact
|
|
95
104
|
|
|
@@ -150,20 +159,15 @@ module Samagotchi
|
|
|
150
159
|
end
|
|
151
160
|
|
|
152
161
|
# Block until answered/cancelled (cross-thread path: WEB/Bridge/background worker)
|
|
162
|
+
closed_reason = wait_for_answer(watch)
|
|
153
163
|
answer = nil
|
|
154
164
|
cancelled_reason = nil
|
|
155
165
|
@lock.synchronize do
|
|
156
|
-
loop do
|
|
157
|
-
break if @answer
|
|
158
|
-
break if cancel_controller&.cancelled?
|
|
159
|
-
break if @pending.nil? || @pending[:status] != "pending"
|
|
160
|
-
|
|
161
|
-
# Wait with timeout to check cancel; 0.2s matches reminder poll
|
|
162
|
-
@cv.wait(0.2)
|
|
163
|
-
end
|
|
164
166
|
answer = @answer
|
|
165
167
|
controller = cancel_controller
|
|
166
|
-
|
|
168
|
+
if answer.nil?
|
|
169
|
+
cancelled_reason = closed_reason || (controller.reason.to_s if controller&.cancelled?)
|
|
170
|
+
end
|
|
167
171
|
@pending = nil
|
|
168
172
|
end
|
|
169
173
|
# Saved and announced outside the question lock (see the sync path).
|
|
@@ -185,16 +189,19 @@ module Samagotchi
|
|
|
185
189
|
# @param id [String] pending id
|
|
186
190
|
# @param selected [Array<String>] values/labels
|
|
187
191
|
# @param freeform [String, nil]
|
|
188
|
-
# @param client_id [String, nil] who answers
|
|
189
|
-
#
|
|
190
|
-
#
|
|
192
|
+
# @param client_id [String, nil] who answers
|
|
193
|
+
# @param parent_agent [Boolean, nil] the answer is a parent agent's, so
|
|
194
|
+
# it is held to guardrails.parent_approvals on an approval and doesn't
|
|
195
|
+
# bring the session back to the lists; nil: chi answer's client id says so
|
|
196
|
+
# @return [Hash] normalized answer; a parent agent's carries by: "parent_agent"
|
|
191
197
|
# @raise [NotPending, ArgumentError, Refused]
|
|
192
|
-
def answer(id:, selected:, freeform: nil, client_id: nil)
|
|
198
|
+
def answer(id:, selected:, freeform: nil, client_id: nil, parent_agent: nil)
|
|
193
199
|
sel = Array(selected).map { |v| v.to_s.strip }.reject(&:empty?)
|
|
194
200
|
fm = freeform.to_s.strip
|
|
195
201
|
fm = nil if fm.empty?
|
|
202
|
+
parent_agent = client_id.to_s == Guardrails::ParentApprovals::CLIENT_ID if parent_agent.nil?
|
|
196
203
|
# Read before the lock (config may touch the disk); this worker's own.
|
|
197
|
-
parent_setting = Guardrails::ParentApprovals.setting if
|
|
204
|
+
parent_setting = Guardrails::ParentApprovals.setting if parent_agent
|
|
198
205
|
@lock.synchronize do
|
|
199
206
|
pending = @pending
|
|
200
207
|
raise NotPending, "no pending question" unless pending
|
|
@@ -228,19 +235,50 @@ module Samagotchi
|
|
|
228
235
|
answer = { id: id.to_s, selected: sel, freeform: fm }
|
|
229
236
|
# Derive indices for convenience
|
|
230
237
|
answer[:selected_indices] = indices.compact
|
|
238
|
+
answer[:by] = "parent_agent" if parent_agent
|
|
231
239
|
@answer = answer
|
|
232
240
|
@cv.broadcast
|
|
233
241
|
answer
|
|
234
242
|
end.tap do
|
|
235
243
|
# A human answered: the session is back in the lists (ArchiveStore).
|
|
236
|
-
@user_input.call(session&.id)
|
|
244
|
+
@user_input.call(session&.id) unless parent_agent
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Mark the pending question as relayed to a parent session's user, or
|
|
249
|
+
# clear the mark (relayed_to: nil): saved with it and announced as
|
|
250
|
+
# :question_relay, so every UI shows where else it can be answered. Under
|
|
251
|
+
# the question lock, so the save never lands after the turn thread
|
|
252
|
+
# cleared the question.
|
|
253
|
+
# @param relayed_to [Hash, nil] {parent_id:, parent_short:, relay_id:}
|
|
254
|
+
# @param reason [String, nil] why a mark was cleared (parent_gone, …)
|
|
255
|
+
# @return [Boolean] false when +id+ isn't the question pending now
|
|
256
|
+
def annotate(id, relayed_to:, reason: nil)
|
|
257
|
+
marked = @lock.synchronize do
|
|
258
|
+
pending = @pending
|
|
259
|
+
next nil unless pending && pending[:id].to_s == id.to_s
|
|
260
|
+
next nil if @answer || pending[:status].to_s != "pending"
|
|
261
|
+
|
|
262
|
+
relayed_to ? pending[:relayed_to] = relayed_to : pending.delete(:relayed_to)
|
|
263
|
+
if session
|
|
264
|
+
session.pending_question = pending.dup
|
|
265
|
+
begin; session.save(state_dir: state_dir); rescue StandardError; nil; end
|
|
266
|
+
end
|
|
267
|
+
pending[:id]
|
|
237
268
|
end
|
|
269
|
+
return false unless marked
|
|
270
|
+
|
|
271
|
+
emit({ type: :question_relay, id: marked, relayed_to: relayed_to, reason: reason }.compact.merge(relayed_to: relayed_to))
|
|
272
|
+
true
|
|
238
273
|
end
|
|
239
274
|
|
|
240
275
|
def sync_handler=(block)
|
|
241
276
|
@sync_handler = block
|
|
242
277
|
end
|
|
243
278
|
|
|
279
|
+
# @return [Boolean] a REPL answers inline (no wait loop)
|
|
280
|
+
def sync_handler? = !@sync_handler.nil?
|
|
281
|
+
|
|
244
282
|
# Cancel the pending question (e.g. /cancel, a dismiss). Announces which
|
|
245
283
|
# one, so every UI closes it; with none pending there is nothing to
|
|
246
284
|
# announce. A question already answered (the turn thread hasn't taken the
|
|
@@ -292,6 +330,47 @@ module Samagotchi
|
|
|
292
330
|
|
|
293
331
|
private
|
|
294
332
|
|
|
333
|
+
# Wait until the question is answered, cancelled or closed by +watch+.
|
|
334
|
+
# @return [String, nil] the watch's close reason
|
|
335
|
+
def wait_for_answer(watch)
|
|
336
|
+
next_watch = monotonic + WATCH_INTERVAL
|
|
337
|
+
loop do
|
|
338
|
+
done = @lock.synchronize do
|
|
339
|
+
next true if @answer || cancel_controller&.cancelled?
|
|
340
|
+
next true if @pending.nil? || @pending[:status] != "pending"
|
|
341
|
+
|
|
342
|
+
# Wait with timeout to check cancel; 0.2s matches reminder poll
|
|
343
|
+
@cv.wait([0.2, watch ? next_watch - monotonic : 0.2].min.clamp(0.0, 0.2))
|
|
344
|
+
false
|
|
345
|
+
end
|
|
346
|
+
return nil if done
|
|
347
|
+
next unless watch && monotonic >= next_watch
|
|
348
|
+
|
|
349
|
+
next_watch = monotonic + WATCH_INTERVAL
|
|
350
|
+
reason = run_watch(watch)
|
|
351
|
+
next unless reason
|
|
352
|
+
|
|
353
|
+
closed = @lock.synchronize do
|
|
354
|
+
next false if @answer || @pending.nil? || @pending[:status] != "pending"
|
|
355
|
+
|
|
356
|
+
@pending[:status] = "cancelled"
|
|
357
|
+
true
|
|
358
|
+
end
|
|
359
|
+
return reason if closed
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# The watch's close reason; one that raises closes nothing.
|
|
364
|
+
def run_watch(watch)
|
|
365
|
+
reason = watch.call
|
|
366
|
+
reason&.to_s
|
|
367
|
+
rescue StandardError => e
|
|
368
|
+
Log.warn(:turn, "question_watch_failed", error: e.class.name, message: e.message)
|
|
369
|
+
nil
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
373
|
+
|
|
295
374
|
def session = @session_lookup.call
|
|
296
375
|
def state_dir = @state_dir_lookup.call
|
|
297
376
|
def cancel_controller = @cancel_controller_lookup.call
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "guardrails/approval"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A delegate's approval reopened as the parent session's own (the
|
|
7
|
+
# approval relay): the same kind "approval" and the same approval facts,
|
|
8
|
+
# so every UI shows it as it shows the parent's own approvals, the diff
|
|
9
|
+
# included, and Guardrails::ParentApprovals judges it the same way. The
|
|
10
|
+
# question text names the delegate and its task over the child's own
|
|
11
|
+
# text; `relay:` says which child and which of its questions it answers.
|
|
12
|
+
# A relay of a relay (a grandchild's) grows relay.chain.
|
|
13
|
+
module Relay
|
|
14
|
+
# How much of the child's task the card keeps, and the question line.
|
|
15
|
+
TASK_CHARS = 200
|
|
16
|
+
TASK_LINE_CHARS = 80
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# @param child [Session] the child as loaded (its id, its first prompt)
|
|
21
|
+
# @param pending [Hash] the child's pending question (string keys inside
|
|
22
|
+
# when it came from a saved session)
|
|
23
|
+
# @param relay_id [String] RelayDesk's id
|
|
24
|
+
# @param more [Integer] other delegates' approvals waiting behind this one
|
|
25
|
+
# @return [Hash] Engine#open_question fields
|
|
26
|
+
def card(child, pending, relay_id:, more: 0)
|
|
27
|
+
pending = deep_symbolize(pending)
|
|
28
|
+
facts = pending[:approval].is_a?(Hash) ? pending[:approval] : {}
|
|
29
|
+
short = child.id.to_s[0, 8]
|
|
30
|
+
inner = pending[:relay].is_a?(Hash) ? pending[:relay] : nil
|
|
31
|
+
chain = inner ? [*Array(inner[:chain]), short] : [short]
|
|
32
|
+
asked = inner ? inner[:asked].to_s : pending[:question].to_s
|
|
33
|
+
task = inner ? inner[:task].to_s : task_of(child)
|
|
34
|
+
who = chain.reverse.join(" → ")
|
|
35
|
+
{
|
|
36
|
+
question: question_text(who, task, asked),
|
|
37
|
+
options: relabel(Array(pending[:options]), facts[:scopes], who),
|
|
38
|
+
header: header(who, more),
|
|
39
|
+
multi_select: false,
|
|
40
|
+
allow_freeform: pending.key?(:allow_freeform) ? !!pending[:allow_freeform] : true,
|
|
41
|
+
kind: Guardrails::Approval::KIND,
|
|
42
|
+
approval: facts,
|
|
43
|
+
relay: { id: relay_id, child_id: child.id, child_short: short, child_question_id: pending[:id].to_s,
|
|
44
|
+
task: task, chain: chain, asked: asked, more: more.to_i }
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# delegate ab12 ("fix the flaky spec in …") asks:
|
|
49
|
+
# execute: git push origin main
|
|
50
|
+
# in /path/repo (repo samagotchi, branch main)
|
|
51
|
+
def question_text(who, task, asked)
|
|
52
|
+
line = task.empty? ? "delegate #{who} asks:" : "delegate #{who} (#{cut(task, TASK_LINE_CHARS).inspect}) asks:"
|
|
53
|
+
[line, *asked.lines.map { |l| " #{l.chomp}" }].join("\n")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def header(who, more)
|
|
57
|
+
text = "Approve delegate #{who}'s tool call?"
|
|
58
|
+
more.to_i.positive? ? "#{text} (+#{more} more delegate#{"s" if more.to_i > 1} waiting)" : text
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The child's options, by index: "session" is the child's session, so
|
|
62
|
+
# it says whose; "once", "repo", "rule" and Deny keep their labels (the
|
|
63
|
+
# approvals store is shared, a session scope keys on the child's id).
|
|
64
|
+
def relabel(options, scopes, who)
|
|
65
|
+
scopes = Array(scopes)
|
|
66
|
+
options.each_with_index.map do |label, index|
|
|
67
|
+
scopes[index].to_s == "session" ? "Allow this call for delegate #{who}'s session" : label.to_s
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The child's task: its first prompt, on one line, cut.
|
|
72
|
+
def task_of(child)
|
|
73
|
+
raw = [child.first_preview, child.last_prompt].map(&:to_s).find { |text| !text.strip.empty? }
|
|
74
|
+
cut(raw.to_s.gsub(/\s+/, " ").strip, TASK_CHARS)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def cut(text, limit) = text.length > limit ? "#{text[0, limit - 1]}…" : text
|
|
78
|
+
|
|
79
|
+
# Session.load symbolizes only top-level keys.
|
|
80
|
+
def deep_symbolize(value)
|
|
81
|
+
case value
|
|
82
|
+
when Hash then value.to_h { |key, inner| [key.to_sym, deep_symbolize(inner)] }
|
|
83
|
+
when Array then value.map { |inner| deep_symbolize(inner) }
|
|
84
|
+
else value
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
# A parent worker's relays of its delegates' approvals: relay id → the
|
|
7
|
+
# child and its question, and the answer this worker's own question flow
|
|
8
|
+
# took from a UI. A child believes a relayed answer only when it asked
|
|
9
|
+
# this table for it, through the parent's Bridge (POST relay/status).
|
|
10
|
+
#
|
|
11
|
+
# Memory only: never in the session file, the event log, a snapshot, a
|
|
12
|
+
# log line or the environment, so nothing but this worker's question flow
|
|
13
|
+
# writes it. Entries are dropped KEEP_SECONDS after they settle.
|
|
14
|
+
class RelayDesk
|
|
15
|
+
KEEP_SECONDS = 600
|
|
16
|
+
|
|
17
|
+
# @param clock [#call] monotonic seconds (specs)
|
|
18
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
19
|
+
@clock = clock
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@relays = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [String] the new relay's id (a UUID: an id, not a secret)
|
|
25
|
+
def open(child_id:, child_question_id:)
|
|
26
|
+
id = SecureRandom.uuid
|
|
27
|
+
@mutex.synchronize do
|
|
28
|
+
prune
|
|
29
|
+
@relays[id] = { child_id: child_id.to_s, child_question_id: child_question_id.to_s, state: "open",
|
|
30
|
+
answer: nil, by: nil, settled_at: nil }
|
|
31
|
+
end
|
|
32
|
+
id
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The parent's user (or a parent agent) answered the relay card.
|
|
36
|
+
# @param by [String] "user" or "parent_agent"
|
|
37
|
+
# @return [Boolean] false when the relay isn't open (first answer wins)
|
|
38
|
+
def record(id, selected_indices: [], freeform: nil, dismissed: false, by:)
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
relay = @relays[id.to_s]
|
|
41
|
+
next false unless relay && relay[:state] == "open"
|
|
42
|
+
|
|
43
|
+
relay.merge!(state: "answered", by: by.to_s, settled_at: @clock.call,
|
|
44
|
+
answer: { selected_indices: Array(selected_indices), freeform: freeform, dismissed: !!dismissed })
|
|
45
|
+
true
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The relay ended with no answer (a Stop, the child gone or answered).
|
|
50
|
+
def close(id)
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
relay = @relays[id.to_s]
|
|
53
|
+
relay&.merge!(state: "closed", settled_at: @clock.call) if relay && relay[:state] == "open"
|
|
54
|
+
end
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Hash, nil] {child_id:, child_question_id:, state:, answer:, by:}
|
|
59
|
+
def status(id)
|
|
60
|
+
@mutex.synchronize do
|
|
61
|
+
prune
|
|
62
|
+
@relays[id.to_s]&.except(:settled_at)&.then { |relay| Marshal.load(Marshal.dump(relay)) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def prune
|
|
69
|
+
now = @clock.call
|
|
70
|
+
@relays.delete_if { |_, relay| relay[:settled_at] && now - relay[:settled_at] > KEEP_SECONDS }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|