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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +135 -1
  3. data/README.md +2 -0
  4. data/docs/architecture.md +23 -0
  5. data/docs/cli.md +7 -4
  6. data/docs/configuration.md +7 -5
  7. data/docs/guardrails.md +16 -2
  8. data/docs/internals/background-tasks.md +1 -0
  9. data/docs/memory.md +39 -0
  10. data/docs/plugins.md +2 -0
  11. data/docs/sessions.md +10 -4
  12. data/docs/sub-agent.md +16 -3
  13. data/docs/testing.md +47 -0
  14. data/lib/samagotchi/answer_display.rb +6 -2
  15. data/lib/samagotchi/bridge/card_store.rb +16 -1
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +26 -5
  17. data/lib/samagotchi/bridge.rb +122 -7
  18. data/lib/samagotchi/bridge_client.rb +33 -6
  19. data/lib/samagotchi/bundle_command.rb +13 -2
  20. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  21. data/lib/samagotchi/bundles/system/memory_guide.md +5 -5
  22. data/lib/samagotchi/bundles/system/self_map.md +3 -1
  23. data/lib/samagotchi/empty_answer_retry.rb +2 -1
  24. data/lib/samagotchi/engine.rb +82 -18
  25. data/lib/samagotchi/kernel_loop.rb +16 -5
  26. data/lib/samagotchi/llm/chat_loop.rb +14 -8
  27. data/lib/samagotchi/llm/model_result.rb +12 -7
  28. data/lib/samagotchi/log_subscriber.rb +1 -0
  29. data/lib/samagotchi/memory_bundle/builder.rb +22 -0
  30. data/lib/samagotchi/memory_bundle/index_updater.rb +17 -6
  31. data/lib/samagotchi/memory_bundle/installer.rb +60 -21
  32. data/lib/samagotchi/memory_bundle/profile.rb +9 -4
  33. data/lib/samagotchi/memory_bundle/provenance.rb +14 -0
  34. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -3
  35. data/lib/samagotchi/memory_bundle/system_bundle.rb +5 -3
  36. data/lib/samagotchi/memory_bundle/trash.rb +57 -0
  37. data/lib/samagotchi/memory_bundle/uninstaller.rb +34 -5
  38. data/lib/samagotchi/parent_report.rb +8 -0
  39. data/lib/samagotchi/plugin/context.rb +14 -2
  40. data/lib/samagotchi/question_desk.rb +97 -18
  41. data/lib/samagotchi/relay.rb +88 -0
  42. data/lib/samagotchi/relay_desk.rb +73 -0
  43. data/lib/samagotchi/relay_verifier.rb +113 -0
  44. data/lib/samagotchi/relay_watcher.rb +46 -0
  45. data/lib/samagotchi/reply_wait.rb +10 -1
  46. data/lib/samagotchi/self_report.rb +49 -3
  47. data/lib/samagotchi/send_command.rb +45 -6
  48. data/lib/samagotchi/session.rb +15 -2
  49. data/lib/samagotchi/session_manager.rb +3 -2
  50. data/lib/samagotchi/session_metrics.rb +154 -48
  51. data/lib/samagotchi/sessions_command.rb +10 -4
  52. data/lib/samagotchi/system_prompt.rb +45 -4
  53. data/lib/samagotchi/terminal_ui/attach_launcher.rb +11 -2
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -21
  55. data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -2
  56. data/lib/samagotchi/terminal_ui/formatting.rb +7 -0
  57. data/lib/samagotchi/terminal_ui/question_prompt.rb +48 -2
  58. data/lib/samagotchi/terminal_ui/question_slot.rb +5 -1
  59. data/lib/samagotchi/terminal_ui.rb +24 -0
  60. data/lib/samagotchi/tool_declarations.rb +2 -2
  61. data/lib/samagotchi/tools/builtins.rb +11 -3
  62. data/lib/samagotchi/tools/delegate_relay.rb +220 -0
  63. data/lib/samagotchi/tools/delegate_wait.rb +72 -6
  64. data/lib/samagotchi/tools/peers.rb +7 -4
  65. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  66. data/lib/samagotchi/turn_note.rb +30 -2
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +22 -1
  69. data/lib/samagotchi/web/public/app.js +60 -33
  70. data/lib/samagotchi/web/public/index.html +7 -0
  71. data/lib/samagotchi/web/public/notify.js +22 -5
  72. data/lib/samagotchi/web/public/question_card.js +45 -3
  73. data/lib/samagotchi/web/public/question_cards.js +49 -1
  74. data/lib/samagotchi/web/public/sessions_list.js +8 -1
  75. data/lib/samagotchi/web/public/stage_model.js +8 -3
  76. data/lib/samagotchi/web/public/stage_view.js +20 -3
  77. data/lib/samagotchi/web/public/strip.js +11 -0
  78. data/lib/samagotchi/web/public/timing.js +37 -15
  79. data/lib/samagotchi/web/public/turn_events.js +22 -3
  80. data/lib/samagotchi/web/public/turn_view.js +34 -12
  81. data/lib/samagotchi/worker.rb +28 -2
  82. data/lib/samagotchi/worker_sidecar.rb +4 -1
  83. 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
- UninstallResult = Struct.new(:name, :removed, :blocked, :gone, keyword_init: true)
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).run
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 version.
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
- Dir.glob(File.join(row.source_dir, "*.md")).map { |f| File.basename(f) }.sort.select do |key|
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: edited here and changed in the
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
- gem_manifest.files.keys.map(&:to_s).sort.select do |key|
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
- attr_reader :warnings, :removed_files
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
- FileUtils.rm_f(target_path)
62
- @removed_files << file_key_str
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 => _e
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+ and +state_dir+ are what ctx.sessions forks with.
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, keyword_init: true)
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
- multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform])
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
- cancelled_reason = controller.reason.to_s if answer.nil? && controller&.cancelled?
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; chi answer's (a parent
189
- # agent) is held to guardrails.parent_approvals on an approval
190
- # @return [Hash] normalized answer
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 client_id.to_s == Guardrails::ParentApprovals::CLIENT_ID
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