samagotchi 0.87.0 → 0.89.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -1
  3. data/docs/bundle-authoring.md +19 -0
  4. data/docs/cli.md +44 -5
  5. data/docs/guardrails.md +6 -1
  6. data/docs/internals/bundles.md +10 -0
  7. data/docs/sessions.md +4 -4
  8. data/docs/sub-agent.md +9 -0
  9. data/lib/samagotchi/bundle_command/test_run.rb +2 -2
  10. data/lib/samagotchi/bundle_command/try/run.rb +162 -0
  11. data/lib/samagotchi/bundle_command/try/sandbox.rb +159 -0
  12. data/lib/samagotchi/bundle_command/try/trace.rb +152 -0
  13. data/lib/samagotchi/bundle_command.rb +216 -10
  14. data/lib/samagotchi/bundle_session_settings.rb +2 -0
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/child_reports.rb +100 -7
  17. data/lib/samagotchi/context_providers.rb +1 -2
  18. data/lib/samagotchi/guardrails/parent_policy.rb +20 -0
  19. data/lib/samagotchi/hooks/bundle_loader.rb +2 -0
  20. data/lib/samagotchi/installed_gem.rb +11 -0
  21. data/lib/samagotchi/memory_bundle/asset_installer.rb +25 -13
  22. data/lib/samagotchi/memory_bundle/installed_bundle.rb +6 -3
  23. data/lib/samagotchi/memory_bundle/installer.rb +22 -3
  24. data/lib/samagotchi/memory_bundle/provenance.rb +21 -6
  25. data/lib/samagotchi/relay.rb +34 -2
  26. data/lib/samagotchi/relay_desk.rb +13 -0
  27. data/lib/samagotchi/session_manager.rb +2 -10
  28. data/lib/samagotchi/terminal_ui/attach_launcher.rb +1 -1
  29. data/lib/samagotchi/terminal_ui/question_prompt.rb +2 -1
  30. data/lib/samagotchi/terminal_ui.rb +1 -1
  31. data/lib/samagotchi/tools/delegate_relay.rb +69 -15
  32. data/lib/samagotchi/tools/delegate_wait.rb +3 -1
  33. data/lib/samagotchi/version.rb +1 -1
  34. data/lib/samagotchi/web/public/notify.js +6 -2
  35. data/lib/samagotchi/web/public/question_card.js +2 -0
  36. data/lib/samagotchi/web/public/question_cards.js +25 -18
  37. data/lib/samagotchi/worker.rb +51 -9
  38. metadata +24 -1
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../../analytics_file"
5
+ require_relative "../../session_metrics"
6
+
7
+ module Samagotchi
8
+ class BundleCommand
9
+ module Try
10
+ Trace = Data.define(
11
+ :session_id, :exit_code, :answer, :tools, :notices, :cards, :questions, :denied,
12
+ :interactive_blocked, :used_memories, :tokens, :cost, :cost_is_estimate, :model, :sandbox, :stderr_tail
13
+ )
14
+
15
+ class TraceReader
16
+ DENIAL_TEXT = "No one to approve it (non-interactive run)."
17
+ SESSION_ID = /\A[A-Za-z0-9_-]+\z/
18
+ STDERR_LIMIT = 4000
19
+
20
+ def initialize(state_home:, session_id:)
21
+ @state_home = state_home
22
+ @session_id = session_id if session_id.to_s.match?(SESSION_ID)
23
+ end
24
+
25
+ def read(exit_code:, answer:, stderr:, sandbox:)
26
+ return empty_trace(exit_code, answer, stderr, sandbox) unless @session_id
27
+
28
+ sessions_dir = File.join(@state_home, "samagotchi", "sessions")
29
+ session_path = File.join(sessions_dir, "#{@session_id}.json")
30
+ session_dir = File.join(sessions_dir, @session_id)
31
+ session = json_file(session_path) || {}
32
+ messages = Array(session["messages"]).select { |message| message.is_a?(Hash) }
33
+ analytics = AnalyticsFile.read(session_dir) || {}
34
+ entries = card_entries(session_dir)
35
+ tool_rows, denied = tools(messages, analytics)
36
+ summary = SessionMetrics.saved_summary(session_dir)
37
+ cost, estimate = cost_summary(summary)
38
+ questions = entries.select { |entry| entry["type"] == "question" }
39
+ notices = entries.select { |entry| entry["type"] == "hook_notice" }
40
+ cards = entries.select { |entry| entry["type"] == "card" }
41
+ blocked_count = questions.count { |question| unanswerable?(question) } + denied.size
42
+
43
+ Trace.new(
44
+ session_id: @session_id, exit_code: exit_code, answer: answer.to_s, tools: tool_rows, notices: notices,
45
+ cards: cards, questions: questions, denied: denied, interactive_blocked: blocked_count.positive?,
46
+ used_memories: Array(session["used_memory_names"]), tokens: {
47
+ prompt: summary&.prompt_sum, completion: summary&.completion_sum
48
+ },
49
+ cost: cost, cost_is_estimate: estimate, model: session["model_name"], sandbox: sandbox,
50
+ stderr_tail: tail(stderr)
51
+ )
52
+ end
53
+
54
+ private
55
+
56
+ def empty_trace(exit_code, answer, stderr, sandbox)
57
+ Trace.new(session_id: nil, exit_code: exit_code, answer: answer.to_s, tools: [], notices: [], cards: [],
58
+ questions: [], denied: [], interactive_blocked: false, used_memories: [], tokens: {
59
+ prompt: nil, completion: nil
60
+ }, cost: nil, cost_is_estimate: false, model: nil, sandbox: sandbox, stderr_tail: tail(stderr))
61
+ end
62
+
63
+ def tools(messages, analytics)
64
+ records = Array(analytics["tool_records"]).select { |record| record.is_a?(Hash) }
65
+ .to_h do |record|
66
+ [[record["iteration"].to_i, record["call_index"].to_i], record]
67
+ end
68
+ tool_responses = messages.select { |message| %w[tool_response tool].include?(message["role"]) }
69
+ denied = []
70
+ index = 0
71
+ iteration = 0
72
+ rows = messages.flat_map do |message|
73
+ next [] unless %w[model assistant].include?(message["role"])
74
+
75
+ iteration += 1
76
+ Array(message["tool_calls"]).each_with_index.filter_map do |call, call_index|
77
+ next unless call.is_a?(Hash)
78
+
79
+ index += 1
80
+ record = records[[iteration, call_index + 1]]
81
+ id = call["id"]
82
+ response = tool_responses.find { |entry| entry["tool_call_id"] == id } if id
83
+ response ||= tool_responses[index - 1] unless id
84
+ is_denied = response && response["content"].to_s.include?(DENIAL_TEXT)
85
+ denied << index if is_denied
86
+ status = is_denied ? "denied" : record&.dig("status").to_s
87
+ status = "unknown" if status.empty?
88
+ {
89
+ index: index,
90
+ name: record&.[]("called_as") || record&.[]("tool") || call_name(call),
91
+ args: argument_text(call),
92
+ status: status,
93
+ duration_ms: record&.[]("duration_ms").to_i
94
+ }
95
+ end
96
+ end
97
+ [rows, denied]
98
+ end
99
+
100
+ def call_name(call)
101
+ function = call["function"].is_a?(Hash) ? call["function"] : {}
102
+ call["name"] || function["name"] || "tool"
103
+ end
104
+
105
+ def argument_text(call)
106
+ function = call["function"].is_a?(Hash) ? call["function"] : {}
107
+ args = call["arguments"] || function["arguments"] || {}
108
+ args = JSON.parse(args) if args.is_a?(String)
109
+ if args.is_a?(Hash)
110
+ preferred = %w[command content path url query].filter_map { |key| args[key] }.first
111
+ args = preferred || JSON.generate(args)
112
+ end
113
+ args.to_s.gsub(/\s+/, " ").strip[0, 200]
114
+ rescue JSON::ParserError
115
+ args.to_s.gsub(/\s+/, " ").strip[0, 200]
116
+ end
117
+
118
+ def card_entries(session_dir)
119
+ data = json_file(File.join(session_dir, "cards.json"))
120
+ entries = data.is_a?(Hash) ? data["entries"] : []
121
+ Array(entries).select { |entry| entry.is_a?(Hash) }
122
+ end
123
+
124
+ def unanswerable?(question)
125
+ question["cancelled"] && question["reason"] == "non_interactive"
126
+ end
127
+
128
+ def cost_summary(summary)
129
+ return [nil, false] unless summary
130
+
131
+ reported = summary.cost_sum.to_f
132
+ estimate = summary.cost_estimate_sum.to_f
133
+ return [reported, false] if reported.positive?
134
+ return [nil, false] unless estimate.positive?
135
+
136
+ [estimate, true]
137
+ end
138
+
139
+ def json_file(path)
140
+ JSON.parse(File.read(path))
141
+ rescue JSON::ParserError, SystemCallError
142
+ nil
143
+ end
144
+
145
+ def tail(text)
146
+ value = text.to_s
147
+ value.length > STDERR_LIMIT ? value[-STDERR_LIMIT, STDERR_LIMIT] : value
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
@@ -10,7 +10,7 @@ module Samagotchi
10
10
  # subcommands have their own flags).
11
11
  class BundleCommand
12
12
  USAGE = <<~TEXT
13
- Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check> [options]
13
+ Usage: chi bundle <install|upgrade|uninstall|status|diff|list|build|trash|new|test|check|try> [options]
14
14
 
15
15
  install <source> [--scope system|project] [--force]
16
16
  Install a memory bundle from a directory, zip, tar archive, or git URL.
@@ -52,17 +52,26 @@ module Samagotchi
52
52
  check [PATH] [--fix] [--set KEY=VALUE]
53
53
  Check a bundle you're writing (default: the current dir): manifest, files, sha256 lines,
54
54
  requires_chi, and an install + load in a sandbox. --fix rewrites stale sha256 lines.
55
+
56
+ try PATH -p TEXT [options]
57
+ Run one non-interactive model turn with a bundle installed in an isolated sandbox.
55
58
  TEXT
56
59
 
57
60
  # Each subcommand's --help (and only --help: -h and help are taken as
58
61
  # the source or name).
59
62
  INSTALL_HELP = <<~TEXT
60
63
  Usage: chi bundle install <source> [--scope system|project] [--force]
64
+ chi bundle install --dev PATH
61
65
 
62
66
  install <source> [--scope system|project] [--force]
63
67
  Install a memory bundle from a directory, zip, tar archive, or git URL,
64
68
  or a bundle shipped with chi by name (see: chi bundle list).
65
69
  --force overwrites existing entries; default skips them.
70
+ install --dev PATH
71
+ Install the bundle in the local directory PATH as a link to it: its plugin, hooks, rules and scripts
72
+ are PATH's own files (an edit applies at the next worker or chi web restart, with no re-install) and
73
+ their sha256 isn't checked, for this bundle only. Its memories are copied; re-install with --force to
74
+ pick up memory edits. Upgrade refuses it; uninstall removes the link, never PATH's files.
66
75
  TEXT
67
76
  UPGRADE_HELP = "Usage: chi bundle upgrade <source> [--scope system|project] [--force] [--dry-run] [--agent|--no-agent]"
68
77
  UNINSTALL_HELP = "Usage: chi bundle uninstall <bundle> [--scope system|project] [--force]"
@@ -108,6 +117,19 @@ module Samagotchi
108
117
  --set KEY=VALUE a setting for the load (config.yml bundles: <name>: KEY), repeatable
109
118
  Exit status: 0 no errors, 1 errors, 2 usage.
110
119
  TEXT
120
+ TRY_HELP = <<~TEXT
121
+ Usage: chi bundle try PATH -p TEXT [--model NAME] [--memory NAME] [--mute NAME] [--toolkit NAME]
122
+ [--thinking LEVEL] [--set KEY=VALUE] [--json] [--keep] [--timeout SECONDS]
123
+
124
+ Install PATH and run one non-interactive turn in an isolated temporary config, state and work directory.
125
+ The user's hosts, keys and ordinary runtime settings are retained; their bundles, hooks, guardrails,
126
+ memories, prompt additions, sessions and attached context sources are not. Shipped guardrails and a
127
+ $1 cost cap are installed for the run. --set KEY=VALUE sets the tried bundle's config.yml bundles entry;
128
+ --set cost-cap.cap=N overrides the cap (unlike chi --set BUNDLE.KEY=VALUE, these are config settings).
129
+ --memory and --mute accept comma-separated names and repeat. --keep retains the sandbox and debug log.
130
+ --json prints the experimental machine-readable trace.
131
+ Exit status: child turn's status; 1 on install failure or timeout; 2 for usage; 130 on Ctrl-C.
132
+ TEXT
111
133
 
112
134
  # The subcommands' flags: --help only; a word not starting with -- is
113
135
  # a positional (install's source, -h included). --scope takes whatever
@@ -115,8 +137,12 @@ module Samagotchi
115
137
  INSTALL_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
116
138
  f.value "--scope"
117
139
  f.switch "--force"
140
+ f.switch "--dev"
141
+ end
142
+ UNINSTALL_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
143
+ f.value "--scope"
144
+ f.switch "--force"
118
145
  end
119
- UNINSTALL_FLAGS = INSTALL_FLAGS
120
146
  UPGRADE_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
121
147
  f.value "--scope"
122
148
  f.switch "--force"
@@ -135,6 +161,19 @@ module Samagotchi
135
161
  f.switch "--fix"
136
162
  f.value "--set", key: :set, repeat: true
137
163
  end
164
+ TRY_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A-/) do |f|
165
+ f.value "-p", "--prompt", key: :prompt
166
+ f.value "--model"
167
+ f.value "--memory", repeat: true
168
+ f.value "--mute", repeat: true
169
+ f.value "--toolkit"
170
+ f.value "--thinking"
171
+ f.value "--set", repeat: true
172
+ f.switch "--json"
173
+ f.switch "--keep"
174
+ f.value "--timeout"
175
+ f.refuse "--attach", "--shared", "chi bundle try can't use --attach or --shared"
176
+ end
138
177
  TRASH_FLAGS = CLI::Flags.new(help: %w[--help], flag_pattern: /\A--/) do |f|
139
178
  f.switch "--empty"
140
179
  f.switch "--dry-run"
@@ -169,8 +208,9 @@ module Samagotchi
169
208
  when "new" then new_bundle(@argv[1..])
170
209
  when "test" then test_bundle(@argv[1..])
171
210
  when "check" then check(@argv[1..])
211
+ when "try" then try_bundle(@argv[1..])
172
212
  else
173
- @stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build, trash, new, test, check"
213
+ @stderr.puts "Unknown bundle subcommand: #{sub}. Use: install, upgrade, uninstall, status, diff, list, build, trash, new, test, check, try"
174
214
  CLI::Exit::USAGE
175
215
  end
176
216
  end
@@ -185,11 +225,21 @@ module Samagotchi
185
225
  source = parsed.args.last
186
226
  scope = opts[:scope]
187
227
  force = opts.fetch(:force, false)
228
+ dev = opts.fetch(:dev, false)
188
229
  if source.nil? || source.empty?
189
230
  @stderr.puts "Usage: chi bundle install <source> [--scope system|project] [--force]"
190
231
  return CLI::Exit::USAGE
191
232
  end
192
- expanded_source = expand_source(source)
233
+ if dev && (Samagotchi::MemoryBundle::SourceNormalizer.git_url?(source) || !File.directory?(source))
234
+ @stderr.puts "chi bundle install --dev takes a local directory (the bundle's source), not #{source}"
235
+ return CLI::Exit::USAGE
236
+ end
237
+ expanded_source = dev ? File.realpath(source) : expand_source(source)
238
+ if dev && File.dirname(expanded_source) == File.realpath(Samagotchi::MemoryBundle::SourceNormalizer::SHIPPED_DIR)
239
+ @stderr.puts "chi bundle install --dev cannot be used with chi-shipped bundles or profiles; " \
240
+ "use chi bundle install #{File.basename(expanded_source)} instead"
241
+ return CLI::Exit::USAGE
242
+ end
193
243
  if Samagotchi::MemoryBundle::Profile.shipped_meta?(expanded_source)
194
244
  return install_profile(expanded_source, scope: scope, force: force, dry_run: false, word: "Install")
195
245
  end
@@ -198,7 +248,7 @@ module Samagotchi
198
248
  return 1 unless prepared
199
249
  return 1 if installed_under_another_name?(expanded_source, bundle_name, prepared, "Install")
200
250
 
201
- installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force)
251
+ installer = installer_for(expanded_source, bundle_name, prepared: prepared, scope: scope, force: force, dev: dev)
202
252
  run_installer(installer, failed: "Install") do |manifest|
203
253
  print_hooks_and_plugin(manifest)
204
254
  print_provenance(bundle_name, manifest)
@@ -457,7 +507,7 @@ module Samagotchi
457
507
  @stdout.puts "Bundle '#{name}' not installed."
458
508
  return 0
459
509
  end
460
- @stdout.puts "Bundle: #{name} v#{st[:provenance].version} scope=#{status_scope(st)} installed=#{st[:provenance].installed_at}"
510
+ @stdout.puts "Bundle: #{name} v#{st[:provenance].version}#{dev_label(st[:provenance])} scope=#{status_scope(st)} installed=#{st[:provenance].installed_at}"
461
511
  if st[:scope_error]
462
512
  @stdout.puts "Target: (unknown scope: this chi can't resolve it; upgrade chi or reinstall the bundle)"
463
513
  else
@@ -519,12 +569,19 @@ module Samagotchi
519
569
  plugin_info = plugin ? " plugin=#{plugin[:file]}" : ""
520
570
  includes = st[:provenance].includes
521
571
  includes_info = includes ? " includes=#{includes.join(",")}" : ""
522
- @stdout.puts " #{bname} v#{st[:provenance].version} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
572
+ @stdout.puts " #{bname} v#{st[:provenance].version}#{dev_label(st[:provenance])} scope=#{status_scope(st)} files=#{st[:files].size}#{hook_info}#{plugin_info}#{includes_info} issues=#{mods}"
523
573
  end
524
574
  end
525
575
  0
526
576
  end
527
577
 
578
+ # " dev → /path (missing)" when a dev install's source isn't there, else "".
579
+ def dev_label(record)
580
+ return "" unless record.dev
581
+
582
+ " dev → #{record.source}#{" (missing)" unless File.directory?(record.source)}"
583
+ end
584
+
528
585
  # "team (unknown)" for a scope this chi can't resolve (Status#bundle_status).
529
586
  def status_scope(st) = st[:scope_error] ? "#{st[:scope]} (unknown)" : st[:scope]
530
587
 
@@ -844,7 +901,7 @@ module Samagotchi
844
901
  parsed = parse_flags(CHECK_FLAGS, rest, "check", help: CHECK_HELP)
845
902
  return parsed if parsed.is_a?(Integer)
846
903
 
847
- settings = check_settings(parsed.options.fetch(:set, [])) or return CLI::Exit::USAGE
904
+ settings = check_settings(parsed.options.fetch(:set, []), label: "check") or return CLI::Exit::USAGE
848
905
  if parsed.args.size > 1
849
906
  @stderr.puts "chi bundle check takes one PATH (#{parsed.args.join(" ")})"
850
907
  return CLI::Exit::USAGE
@@ -858,14 +915,163 @@ module Samagotchi
858
915
  Check.new(dir, fix: parsed.options.fetch(:fix, false), settings: settings, stdout: @stdout).run
859
916
  end
860
917
 
918
+ def try_bundle(rest)
919
+ parsed = parse_flags(TRY_FLAGS, rest, "try", help: TRY_HELP)
920
+ return parsed if parsed.is_a?(Integer)
921
+
922
+ options = parsed.options
923
+ if options[:prompt].nil? || parsed.args.size != 1
924
+ @stderr.puts "Usage: chi bundle try PATH -p TEXT [options]"
925
+ return CLI::Exit::USAGE
926
+ end
927
+ path = parsed.args.first
928
+ unless File.directory?(path)
929
+ @stderr.puts "chi bundle try: #{path} isn't a folder"
930
+ return CLI::Exit::USAGE
931
+ end
932
+ if options.values_at(:model, :toolkit, :thinking, :memory, :mute).flatten.compact.any?(&:empty?)
933
+ @stderr.puts "chi bundle try: model, toolkit, thinking, memory and mute values cannot be empty"
934
+ return CLI::Exit::USAGE
935
+ end
936
+ require_relative "thinking"
937
+ if options[:thinking] && !Thinking::LEVELS.map(&:to_s).include?(options[:thinking])
938
+ @stderr.puts "chi bundle try: --thinking takes #{Thinking::LEVELS.join(", ")}, not #{options[:thinking]}"
939
+ return CLI::Exit::USAGE
940
+ end
941
+
942
+ timeout = parse_try_timeout(options[:timeout])
943
+ return CLI::Exit::USAGE unless timeout
944
+
945
+ set_pairs = []
946
+ cost_cap = 1
947
+ cost_cap_override = false
948
+ require_relative "bundle_session_settings"
949
+ Array(options[:set]).each do |pair|
950
+ key, sep, value = pair.partition("=")
951
+ if sep.empty?
952
+ @stderr.puts "chi bundle try: --set takes KEY=VALUE, not #{pair}"
953
+ return CLI::Exit::USAGE
954
+ end
955
+ if key == "cost-cap.cap"
956
+ cost_cap = BundleSessionSettings.typed(value)
957
+ cost_cap_override = true
958
+ unless (cost_cap.is_a?(Numeric) && cost_cap >= 0) || cost_cap == false
959
+ @stderr.puts "chi bundle try: --set cost-cap.cap takes a non-negative number or off"
960
+ return CLI::Exit::USAGE
961
+ end
962
+ else
963
+ set_pairs << pair
964
+ end
965
+ end
966
+ settings = check_settings(set_pairs, label: "try")
967
+ return CLI::Exit::USAGE unless settings
968
+
969
+ require_relative "bundle_command/try/sandbox"
970
+ require_relative "bundle_command/try/run"
971
+ require_relative "bundle_command/try/trace"
972
+ sandbox = nil
973
+ begin
974
+ sandbox = Try::Sandbox.new(path, bundle_settings: settings, cost_cap: cost_cap,
975
+ cost_cap_override: cost_cap_override)
976
+ sandbox.install
977
+ result = Try::Run.new(sandbox).run(prompt: options[:prompt], options: options, timeout: timeout)
978
+ trace = Try::TraceReader.new(state_home: sandbox.state_home, session_id: result.session_id)
979
+ .read(exit_code: result.exit_status, answer: result.stdout, stderr: result.stderr,
980
+ sandbox: options[:keep] ? sandbox.root : nil)
981
+ render_try_trace(trace, json: options[:json], child_stderr: result.stderr)
982
+ @stderr.puts "chi bundle try: timed out after #{timeout} seconds" if result.timed_out
983
+ result.exit_status
984
+ rescue Try::Sandbox::Error => e
985
+ @stderr.puts e.message
986
+ e.status
987
+ ensure
988
+ report_try_sandbox(sandbox) if options[:keep] && sandbox
989
+ sandbox&.cleanup unless options[:keep]
990
+ end
991
+ end
992
+
993
+ def render_try_trace(trace, json:, child_stderr:)
994
+ error_lines = child_stderr.lines.reject { |line| line.start_with?("Session: ") }
995
+ @stderr.write(error_lines.join)
996
+ if json
997
+ render_trace_lines(trace, @stderr, include_answer: false)
998
+ @stdout.puts JSON.generate(trace.to_h.transform_keys(&:to_s))
999
+ else
1000
+ render_trace_lines(trace, @stdout, include_answer: true)
1001
+ end
1002
+ end
1003
+
1004
+ def report_try_sandbox(sandbox)
1005
+ @stderr.puts "sandbox: #{sandbox.root}"
1006
+ @stderr.puts "debug log: #{File.join(sandbox.state_home, "samagotchi", "samagotchi.log")}"
1007
+ end
1008
+
1009
+ def render_trace_lines(trace, output, include_answer:)
1010
+ output.puts "tools:"
1011
+ trace.tools.each do |tool|
1012
+ output.puts format(" %d %-16s %-7s %dms %s", tool[:index], tool[:name], tool[:status],
1013
+ tool[:duration_ms], JSON.generate(tool[:args].to_s[0, 60]))
1014
+ end
1015
+ trace.notices.each { |notice| output.puts "notices: [#{notice["hook"] || "hook"}] #{notice["text"]}" }
1016
+ trace.cards.each do |card|
1017
+ output.puts "card: #{[card["title"], card["body"]].compact.join(" — ")}"
1018
+ end
1019
+ trace.questions.each do |entry|
1020
+ question = entry.dig("pending_question", "question").to_s
1021
+ outcome = if entry["cancelled"] && entry["reason"] == "non_interactive"
1022
+ "unanswerable (non_interactive)"
1023
+ elsif entry["cancelled"]
1024
+ "cancelled (#{entry["reason"]})"
1025
+ elsif entry["answer"]
1026
+ "answered"
1027
+ else
1028
+ "unanswered"
1029
+ end
1030
+ output.puts "questions: #{JSON.generate(question)} -> #{outcome}"
1031
+ end
1032
+ if trace.interactive_blocked
1033
+ count = trace.denied.size + trace.questions.count { |question| question["cancelled"] && question["reason"] == "non_interactive" }
1034
+ output.puts "warn: #{count} question(s)/call(s) couldn't be answered"
1035
+ end
1036
+ if include_answer
1037
+ answer_lines = trace.answer.lines
1038
+ output.puts "answer:"
1039
+ answer_lines.first(10).each { |line| output.puts " #{line.chomp}" }
1040
+ output.puts " … (truncated)" if answer_lines.size > 10
1041
+ end
1042
+ tokens = trace.tokens
1043
+ prompt = tokens[:prompt] || "unknown"
1044
+ completion = tokens[:completion] || "unknown"
1045
+ cost = if trace.cost
1046
+ "#{"~" if trace.cost_is_estimate}$#{format("%.4f", trace.cost)}"
1047
+ else
1048
+ "unknown"
1049
+ end
1050
+ output.puts "tokens: #{prompt} in / #{completion} out cost #{cost} exit #{trace.exit_code}" \
1051
+ "#{" (#{trace.session_id})" if trace.session_id}"
1052
+ end
1053
+
1054
+ def parse_try_timeout(value)
1055
+ return 300 unless value
1056
+
1057
+ timeout = Float(value)
1058
+ return timeout if timeout.positive? && timeout.finite?
1059
+
1060
+ @stderr.puts "chi bundle try: --timeout must be a positive number"
1061
+ nil
1062
+ rescue ArgumentError
1063
+ @stderr.puts "chi bundle try: --timeout must be a positive number"
1064
+ nil
1065
+ end
1066
+
861
1067
  # --set KEY=VALUE values as typed (BundleSessionSettings.typed), or nil
862
1068
  # after saying what's wrong.
863
- def check_settings(pairs)
1069
+ def check_settings(pairs, label:)
864
1070
  require_relative "bundle_session_settings"
865
1071
  pairs.each_with_object({}) do |pair, out|
866
1072
  key, sep, value = pair.partition("=")
867
1073
  unless !sep.empty? && key.match?(BundleSessionSettings::KEY)
868
- @stderr.puts "chi bundle check: --set takes KEY=VALUE, not #{pair}"
1074
+ @stderr.puts "chi bundle #{label}: --set takes KEY=VALUE, not #{pair}"
869
1075
  return nil
870
1076
  end
871
1077
  out[key] = BundleSessionSettings.typed(value)
@@ -176,6 +176,8 @@ module Samagotchi
176
176
  file = File.join(dir, name, "plugin", bundle.plugin.file.to_s)
177
177
  return "the file is missing" unless File.file?(file)
178
178
 
179
+ return nil if MemoryBundle::Provenance.unchecked?(bundle.plugin.sha256)
180
+
179
181
  expected = MemoryBundle::Provenance.recorded_sha(bundle.plugin.sha256)
180
182
  return "no sha256 recorded for it (reinstall the bundle)" if expected.empty?
181
183
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.87.0
3
+ version: 0.89.0
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
@@ -130,7 +130,13 @@ module Samagotchi
130
130
  # (DelegateWait.finish, its report variant), the cursor it moves the child to,
131
131
  # and the cursor it was read from (a commit after the model moved it
132
132
  # itself, with delegate_result, leaves it alone).
133
- Report = Data.define(:child_id, :text, :before, :after, :rings, :claim_id) do
133
+ # +relay+: the child's question when it goes to the parent's user
134
+ # (Tools::DelegateRelay.relay?): #resolve relays it on the turn thread
135
+ # and gives the report its outcome line; +text+ is the question's
136
+ # report until then (what a parent with no relay gets).
137
+ Report = Data.define(:child_id, :text, :before, :after, :rings, :claim_id, :relay) do
138
+ def initialize(relay: nil, **) = super
139
+
134
140
  def line(mark: nil) = Steer::Line.new(text: text, source: ChildReports::SOURCE, mark: mark)
135
141
  def origin = { client_id: "#{ClientId::CHILD_PREFIX}#{child_id[0, 8]}" }
136
142
  end
@@ -146,14 +152,19 @@ module Samagotchi
146
152
  Tools::DelegateCursors.clear_report_claims(@session_id, state_dir: @state_dir)
147
153
  # child id → the Report this turn took for it (rings merged).
148
154
  @taken = {}
155
+ # child id → the question id whose relay card a Stop closed (#hold_back).
156
+ @held = {}
149
157
  end
150
158
 
159
+ # The user's next input: questions held back after a Stop are offered again.
160
+ def release_holds = @held.clear
161
+
151
162
  # Whether a ring this turn hasn't taken waits (the idle loop's check).
152
163
  def waiting?
153
164
  return false if ChildRing.mode == "off"
154
165
 
155
166
  taken = taken_rings
156
- ring_files.any? { |file| !taken.include?(file) }
167
+ ring_files.any? { |file| !taken.include?(file) && !held?(file) }
157
168
  end
158
169
 
159
170
  # The reports for rings this turn hasn't taken yet, recorded as taken.
@@ -164,7 +175,7 @@ module Samagotchi
164
175
  return [] if ChildRing.mode == "off"
165
176
 
166
177
  taken = taken_rings
167
- fresh = ring_files.reject { |file| taken.include?(file) }
178
+ fresh = ring_files.reject { |file| taken.include?(file) || held?(file) }
168
179
  by_child = fresh.group_by { |file| SessionInbox.read_ring(file)&.dig(:child_id) }
169
180
  by_child.filter_map do |child_id, rings|
170
181
  report = child_id && report_for(child_id, rings)
@@ -207,8 +218,79 @@ module Samagotchi
207
218
  @taken = {}
208
219
  end
209
220
 
221
+ # A report whose question goes to the parent's user, settled: relayed
222
+ # through +relay+ (DelegateRelay.call: blocks until the card is
223
+ # answered, closed or the turn stopped) and given the outcome line as
224
+ # its text. Runs on the turn thread, before the boundary merges it.
225
+ # The child's question answered since the ring was taken opens no card;
226
+ # nor does one already answered on a relay (RelayDesk#settled?).
227
+ # @param relay [#open_question, #relay_desk, nil] Engine#relay_peer;
228
+ # nil: the report's question text, as without P4's relay
229
+ # @return [Report, nil] nil when the parent's turn was stopped with the
230
+ # card open: the report is released (its ring and the cursor stay), so
231
+ # the next turn offers it again
232
+ def resolve(report, relay:)
233
+ return report unless report.relay
234
+ return report.with(relay: nil) unless relay
235
+
236
+ question = report.relay
237
+ what = question.header.to_s.empty? ? question.hook.to_s : question.header.to_s
238
+ line = if Session.load(report.child_id, state_dir: @state_dir).pending_question&.id.to_s != question.id.to_s
239
+ Tools::DelegateRelay.line(what, "answered on the child", hook: true)
240
+ elsif relay.relay_desk.settled?(report.child_id, question.id)
241
+ Tools::DelegateRelay.line(what, "answered on a relay already; it waits for the user on the child", hook: true)
242
+ else
243
+ outcome = Tools::DelegateRelay.call(report.child_id, question, relay: relay, state_dir: @state_dir)
244
+ return hold_back(report) if outcome.stopped
245
+
246
+ outcome.line
247
+ end
248
+ resolved = report.with(relay: nil, text: relayed_text(report.child_id, line))
249
+ @taken[report.child_id] = resolved if @taken[report.child_id]&.claim_id == report.claim_id
250
+ resolved
251
+ rescue ArgumentError => e
252
+ # The child is gone (or a bug): its question's report, as without a relay.
253
+ Log.info(:worker, "delegate_relay_unresolved", child: report.child_id[0, 8], error: e.message)
254
+ report.with(relay: nil)
255
+ end
256
+
257
+ # A relay report the parent's turn stopped on, or one behind it in the
258
+ # same take: released (its ring and the cursor stay) and held back, so
259
+ # no wake turn offers that question again until the user's next input
260
+ # (#release_holds); other children's rings go on.
261
+ # @return [nil]
262
+ def hold_back(report)
263
+ @held[report.child_id] = report.relay.id.to_s
264
+ release_report(report)
265
+ end
266
+
210
267
  private
211
268
 
269
+ # A ring of a child whose held-back question still waits.
270
+ def held?(file)
271
+ child_id = SessionInbox.read_ring(file)&.dig(:child_id)
272
+ question_id = @held[child_id] or return false
273
+ Session.load(child_id, state_dir: @state_dir).pending_question&.id.to_s == question_id
274
+ rescue ArgumentError
275
+ false
276
+ end
277
+
278
+ # A relayed question's report: the outcome line, then the next reply's
279
+ # promise (as a report's question text ends).
280
+ def relayed_text(child_id, line)
281
+ Tools::DelegateWait.result(child_id, "question", "#{line}\n#{Tools::DelegateWait::REPORT_NEXT_REPLY}")
282
+ end
283
+
284
+ # One taken report back: its cursor claim goes, its rings stay untaken.
285
+ # @return [nil]
286
+ def release_report(report)
287
+ @taken.delete(report.child_id)
288
+ Tools::DelegateCursors.update(@session_id, report.child_id, state_dir: @state_dir) do |cursor|
289
+ cursor.report_claim&.fetch("id", nil) == report.claim_id ? cursor.with(report_claim: nil) : cursor
290
+ end
291
+ nil
292
+ end
293
+
212
294
  def taken_rings
213
295
  @taken.values.flat_map(&:rings)
214
296
  end
@@ -260,22 +342,33 @@ module Samagotchi
260
342
  # a later wait on it would repeat its reply.
261
343
  after = after.with(report_claim: claim)
262
344
  text = Tools::DelegateWait.finish(wait, child_id, timeout: 0, report: true)
263
- Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id)
345
+ Report.new(child_id: child_id, text: text, before: before, after: after, rings: rings, claim_id: claim_id,
346
+ relay: relay?(wait) ? wait.question : nil)
264
347
  rescue ArgumentError
265
348
  nil # the child is gone
266
349
  end
267
350
 
268
351
  # A reply, a turn that left none, a crash, or a question the parent's
269
- # model decides (its own ask_user_question, the step-limit continue).
270
- # Approvals and hooks' questions are the user's, on the child's card.
352
+ # model decides (its own ask_user_question, the step-limit continue, a
353
+ # hook's question). Approvals are the user's (the relay, while the
354
+ # parent waits; else on the child's card).
271
355
  def reportable?(wait)
272
356
  case wait.status
273
357
  when :done, :no_reply, :error then true
274
- when :waiting_for_answer then [nil, "", "continue"].include?(wait.question&.kind&.to_s)
358
+ when :waiting_for_answer then REPORTED_KINDS.include?(wait.question&.kind&.to_s)
275
359
  else false
276
360
  end
277
361
  end
278
362
 
363
+ REPORTED_KINDS = [nil, "", "continue", Tools::DelegateRelay::HOOK_KIND].freeze
364
+
365
+ # Whether the child's question goes to this parent's user rather than
366
+ # its model (Tools::DelegateRelay.relay?, under the child's strategy).
367
+ def relay?(wait)
368
+ wait.status == :waiting_for_answer && wait.question.kind.to_s == Tools::DelegateRelay::HOOK_KIND &&
369
+ Tools::DelegateRelay.relay?(wait.question, strategy: Guardrails::ParentPolicy.for_session(wait.session))
370
+ end
371
+
279
372
  def delete_rings(rings)
280
373
  rings.each { |file| FileUtils.rm_f(file) }
281
374
  end