samagotchi 0.91.0 → 0.92.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +5 -2
  4. data/bin/chi +4 -4
  5. data/docs/architecture.md +16 -12
  6. data/docs/bundle-authoring.md +2 -1
  7. data/docs/cli.md +5 -5
  8. data/docs/configuration.md +3 -1
  9. data/docs/context.md +5 -3
  10. data/docs/hooks.md +3 -3
  11. data/docs/internals/background-tasks.md +2 -2
  12. data/docs/internals/bundles.md +4 -4
  13. data/docs/internals/engine.md +21 -8
  14. data/docs/internals/tool-guardrails.md +10 -2
  15. data/docs/memory.md +6 -0
  16. data/docs/plugins.md +30 -13
  17. data/docs/releasing.md +3 -0
  18. data/docs/sessions.md +3 -3
  19. data/lib/samagotchi/bridge.rb +8 -9
  20. data/lib/samagotchi/bundle_command/check.rb +43 -12
  21. data/lib/samagotchi/bundle_command/scaffold.rb +12 -0
  22. data/lib/samagotchi/bundle_command/test_run.rb +12 -0
  23. data/lib/samagotchi/bundle_command/try/command.rb +164 -0
  24. data/lib/samagotchi/bundle_command/try/render.rb +80 -0
  25. data/lib/samagotchi/bundle_command/try/run.rb +35 -17
  26. data/lib/samagotchi/bundle_command/try/sandbox.rb +113 -34
  27. data/lib/samagotchi/bundle_command/try/trace.rb +20 -26
  28. data/lib/samagotchi/bundle_command.rb +11 -213
  29. data/lib/samagotchi/bundle_session_settings.rb +82 -45
  30. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  31. data/lib/samagotchi/bundles/cost-cap/manifest.yml +6 -5
  32. data/lib/samagotchi/bundles/cost-cap/plugin.rb +57 -12
  33. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  34. data/lib/samagotchi/child_env.rb +50 -0
  35. data/lib/samagotchi/engine.rb +22 -12
  36. data/lib/samagotchi/hooks/registry.rb +2 -0
  37. data/lib/samagotchi/kernel_loop.rb +14 -3
  38. data/lib/samagotchi/llm/chat_loop.rb +3 -1
  39. data/lib/samagotchi/llm/errors.rb +4 -4
  40. data/lib/samagotchi/memory_bundle/builder.rb +3 -2
  41. data/lib/samagotchi/memory_bundle/installed_bundle.rb +7 -2
  42. data/lib/samagotchi/memory_bundle/installer.rb +1 -0
  43. data/lib/samagotchi/memory_bundle/manifest.rb +42 -8
  44. data/lib/samagotchi/memory_bundle/provenance.rb +2 -1
  45. data/lib/samagotchi/parent_report.rb +12 -0
  46. data/lib/samagotchi/reply_wait.rb +6 -2
  47. data/lib/samagotchi/sandbox_layout.rb +66 -0
  48. data/lib/samagotchi/send_command.rb +3 -3
  49. data/lib/samagotchi/session_manager.rb +12 -20
  50. data/lib/samagotchi/task_watch.rb +64 -21
  51. data/lib/samagotchi/testing/bundle.rb +32 -19
  52. data/lib/samagotchi/testing/install.rb +26 -17
  53. data/lib/samagotchi/testing/sandbox.rb +45 -37
  54. data/lib/samagotchi/testing/waiting.rb +5 -7
  55. data/lib/samagotchi/tool_response.rb +28 -6
  56. data/lib/samagotchi/tools/task_runtime.rb +2 -2
  57. data/lib/samagotchi/turn_checkpoint.rb +23 -10
  58. data/lib/samagotchi/version.rb +1 -1
  59. data/lib/samagotchi/web/public/app.js +24 -14
  60. data/lib/samagotchi/web/public/cards_place.js +4 -1
  61. data/lib/samagotchi/web/public/images.js +6 -0
  62. data/lib/samagotchi/web/public/model_pick.js +10 -0
  63. data/lib/samagotchi/web/public/toasts.js +17 -9
  64. data/lib/samagotchi/web/public/unqueue.js +20 -4
  65. data/lib/samagotchi/worker.rb +77 -185
  66. data/lib/samagotchi/worker_idle_exit.rb +20 -5
  67. data/lib/samagotchi/worker_inbound.rb +5 -5
  68. data/lib/samagotchi/worker_task_wakes.rb +160 -0
  69. data/lib/samagotchi/worker_wakes.rb +0 -3
  70. metadata +6 -1
@@ -2,10 +2,20 @@
2
2
 
3
3
  require "open3"
4
4
  require "rbconfig"
5
+ require_relative "../../process_group"
5
6
 
6
7
  module Samagotchi
7
8
  class BundleCommand
8
9
  module Try
10
+ # The sandboxed child chi of `chi bundle try`, in a process group of
11
+ # its own (ProcessGroup). A --timeout sends it INT (a Ctrl-C: the turn
12
+ # is saved), then after INTERRUPT_GRACE ProcessGroup.stop stops the
13
+ # group and the groups its processes started. After any exit the
14
+ # group is stopped only while something of the child's is still in it
15
+ # (#stop_stragglers): a group signal to a leader reaped with nothing
16
+ # left would reach whatever reuses its pid. What left the group and
17
+ # its parent (a delegate's worker, a task once the child exited) is
18
+ # Sandbox#stop_leftovers', by the records in the sandbox.
9
19
  class Run
10
20
  Result = Data.define(:exit_status, :stdout, :stderr, :session_id, :timed_out)
11
21
  INTERRUPT_GRACE = 5
@@ -30,18 +40,19 @@ module Samagotchi
30
40
  streams = [stdout, stderr]
31
41
  stdin.close
32
42
  readers = streams.map { |stream| Thread.new { read_stream(stream) } }
33
- attach_child(trap_state, pid)
43
+ attach_child(trap_state, pid, wait)
34
44
  timed_out = false
35
45
  status = wait_for(wait, timeout)
36
46
  if status == :timeout
37
47
  timed_out = true
38
- signal_group(pid, "INT")
48
+ ProcessGroup.signal(pid, "INT")
39
49
  status = wait_for(wait, INTERRUPT_GRACE)
40
50
  if status == :timeout
41
- terminate_group(pid)
51
+ ProcessGroup.stop(pid, grace: GROUP_TERM_GRACE, poll: POLL_INTERVAL, stopped: -> { !wait.alive? })
42
52
  status = wait.value
43
53
  end
44
54
  end
55
+ trap_state[:pid] = nil
45
56
  out, err = finish_readers(readers, streams)
46
57
  code = timed_out ? CLI::Exit::FAILED : process_exit_status(status)
47
58
  Result.new(exit_status: code, stdout: out, stderr: err, session_id: err[/^Session: (\S+)/, 1],
@@ -50,7 +61,8 @@ module Samagotchi
50
61
  rescue SystemCallError => e
51
62
  raise Sandbox::Error, "chi bundle try: couldn't run chi: #{e.message}"
52
63
  ensure
53
- terminate_group(pid) if pid
64
+ trap_state[:pid] = nil
65
+ stop_stragglers(pid) if pid
54
66
  close_streams(streams)
55
67
  stop_readers(readers)
56
68
  restore_traps(old_traps)
@@ -79,12 +91,13 @@ module Samagotchi
79
91
  end
80
92
 
81
93
  def trap_forwarding
82
- state = { pid: nil, pending: nil }
94
+ state = { pid: nil, wait: nil, pending: nil }
83
95
  old = {}
84
96
  %w[INT TERM].each do |signal|
85
97
  old[signal] = Signal.trap(signal) do
86
98
  if state[:pid]
87
- signal_group(state[:pid], signal)
99
+ # Not once its waiter reaped it: that group may be gone.
100
+ ProcessGroup.signal(state[:pid], signal) if state[:wait]&.alive?
88
101
  else
89
102
  state[:pending] ||= signal
90
103
  end
@@ -93,27 +106,32 @@ module Samagotchi
93
106
  [state, old]
94
107
  end
95
108
 
96
- def attach_child(state, pid)
109
+ def attach_child(state, pid, wait)
110
+ state[:wait] = wait
97
111
  state[:pid] = pid
98
112
  pending = state[:pending]
99
113
  state[:pending] = nil
100
- signal_group(pid, pending) if pending
114
+ ProcessGroup.signal(pid, pending) if pending
101
115
  end
102
116
 
103
117
  def restore_traps(old)
104
118
  old.each { |signal, handler| Signal.trap(signal, handler) }
105
119
  end
106
120
 
107
- def signal_group(pid, signal)
108
- Process.kill(signal, -pid)
109
- rescue Errno::ESRCH
110
- nil
111
- end
121
+ # Stops what is left in the child's group once the child (its
122
+ # leader) is reaped: a descendant still holding its pipes. Only
123
+ # while one is in it: no process left means no group, and the pid
124
+ # may be reused by now. While a member is left the group's id
125
+ # can't be reused; the reaped leader's pid can, so a group whose
126
+ # member is the pid itself is someone else's.
127
+ def stop_stragglers(pid)
128
+ return unless ProcessGroup.signalable?(pid) && ProcessGroup.group_alive?(pid)
129
+
130
+ members = ProcessGroup.process_table.select { |_pid, _ppid, group| group == pid }.map(&:first)
131
+ return if members.empty? || members.include?(pid)
112
132
 
113
- def terminate_group(pid)
114
- signal_group(pid, "TERM")
115
- sleep(GROUP_TERM_GRACE)
116
- signal_group(pid, "KILL")
133
+ ProcessGroup.stop(pid, grace: GROUP_TERM_GRACE, poll: POLL_INTERVAL,
134
+ stopped: -> { !ProcessGroup.group_alive?(pid) })
117
135
  end
118
136
 
119
137
  def read_stream(stream)
@@ -6,11 +6,18 @@ require "rbconfig"
6
6
  require "tmpdir"
7
7
  require "yaml"
8
8
  require_relative "../../config"
9
+ require_relative "../../child_env"
9
10
  require_relative "../../cli/exit"
10
- require_relative "../../installed_gem"
11
+ require_relative "../../memory_bundle/system_bundle"
11
12
  require_relative "../../memory_bundle"
12
13
  require_relative "../../memory_bundle/shas"
14
+ require_relative "../../owner_lock"
15
+ require_relative "../../process_group"
16
+ require_relative "../../sandbox_layout"
17
+ require_relative "../../session"
13
18
  require_relative "../../session_manager"
19
+ require_relative "../../tools/task_record"
20
+ require_relative "../../tools/task_runtime"
14
21
 
15
22
  module Samagotchi
16
23
  class BundleCommand
@@ -19,18 +26,46 @@ module Samagotchi
19
26
  LIB_DIR = File.expand_path("../../..", __dir__)
20
27
  CHI = File.expand_path("../../../../bin/chi", __dir__)
21
28
  SHIPPED_DIR = MemoryBundle::SourceNormalizer::SHIPPED_DIR
29
+ # The config.yml sections the sandbox drops (and their SAMAGOTCHI_<SECTION>_* env forms); context:
30
+ # loses only its sources: (#write_config).
22
31
  CONFIG_DENYLIST = %w[
23
32
  bundles hooks guardrails memories append_system_prompt recap session log web update kitty tmux broadcast
24
- context_sources
25
33
  ].freeze
26
34
  CONFIG_DENYLIST_ENV_PREFIXES = CONFIG_DENYLIST.map do |section|
27
35
  prefix = if section == "guardrails"
28
- SessionManager::GUARDRAILS_ENV_PREFIX.delete_suffix("_")
36
+ ChildEnv::GUARDRAILS_PREFIX.delete_suffix("_")
29
37
  else
30
38
  "SAMAGOTCHI_#{section.upcase}"
31
39
  end
32
40
  prefix
33
41
  end.freeze
42
+ # `chi bundle install PATH --force` for each PATH in ARGV, in one
43
+ # process (bin/chi's start, once): an install that fails prints
44
+ # what it printed and exits with its status; the ones before it
45
+ # print nothing.
46
+ INSTALL_SCRIPT = <<~RUBY
47
+ require "stringio"
48
+ require "samagotchi/utf8_default"
49
+ Samagotchi::Utf8Default.apply!
50
+ require "samagotchi/config"
51
+ Samagotchi::ConfigFile.load!
52
+ require "samagotchi"
53
+ require "samagotchi/bundle_command"
54
+ ARGV.each do |path|
55
+ out = StringIO.new
56
+ err = StringIO.new
57
+ status = Samagotchi::BundleCommand.new(["install", path, "--force"], stdout: out, stderr: err).run
58
+ next if status.zero?
59
+
60
+ $stdout.write(out.string)
61
+ $stderr.write(err.string)
62
+ exit(status)
63
+ end
64
+ RUBY
65
+ # How long a leftover worker or task gets after TERM (a worker saves
66
+ # its session) before ProcessGroup.stop KILLs it.
67
+ LEFTOVER_GRACE = 3.0
68
+ LEFTOVER_POLL = 0.05
34
69
  class Error < StandardError
35
70
  attr_reader :status
36
71
 
@@ -40,7 +75,7 @@ module Samagotchi
40
75
  end
41
76
  end
42
77
 
43
- attr_reader :root, :config_home, :state_home, :work_dir, :manifest
78
+ attr_reader :root, :config_home, :state_home, :work_dir
44
79
 
45
80
  def initialize(path, bundle_settings: {}, cost_cap: 1, cost_cap_override: false)
46
81
  @path = File.realpath(path)
@@ -48,12 +83,11 @@ module Samagotchi
48
83
  @cost_cap = cost_cap
49
84
  @cost_cap_override = cost_cap_override
50
85
  read_manifest
51
- @root = File.realpath(Dir.mktmpdir("chi-bundle-try-"))
52
- @config_home = File.join(root, "config")
53
- @state_home = File.join(root, "state")
54
- @work_dir = File.join(root, "work")
55
- [config_home, state_home, work_dir].each { |dir| FileUtils.mkdir_p(dir) }
56
- @work_dir = File.realpath(@work_dir)
86
+ @layout = SandboxLayout.create("chi-bundle-try-")
87
+ @root = @layout.root
88
+ @config_home = @layout.config_home
89
+ @state_home = @layout.state_home
90
+ @work_dir = @layout.work_dir
57
91
  write_config
58
92
  @child_env = build_child_env
59
93
  rescue StandardError
@@ -63,20 +97,43 @@ module Samagotchi
63
97
 
64
98
  def child_env = @child_env.dup
65
99
 
100
+ # Installs the tried bundle, then the shipped guardrails and
101
+ # cost-cap (unless it is one of them), each as `chi bundle install
102
+ # PATH --force` in one child chi with the sandbox's environment.
103
+ # @return [MemoryBundle::Manifest]
104
+ # @raise [Error] what the first install that failed printed
66
105
  def install
67
- install_path(@path)
68
- %w[guardrails cost-cap].each do |name|
69
- next if @manifest.name == name
70
-
71
- install_path(File.join(SHIPPED_DIR, name))
106
+ paths = [@path] + %w[guardrails cost-cap].reject { |name| @manifest.name == name }
107
+ .map { |name| File.join(SHIPPED_DIR, name) }
108
+ out, err, status = Open3.capture3(child_env, RbConfig.ruby, "-I", LIB_DIR, "-e", INSTALL_SCRIPT, *paths,
109
+ chdir: work_dir)
110
+ unless status.success?
111
+ message = [out, err].reject(&:empty?).join("\n").strip
112
+ raise Error, "chi bundle try: #{message.empty? ? "bundle install failed" : message}"
72
113
  end
73
114
  @manifest
74
115
  end
75
116
 
76
117
  def cleanup
118
+ stop_leftovers
77
119
  FileUtils.rm_rf(root) if root && File.exist?(root)
78
120
  end
79
121
 
122
+ # Stops what the run started that outlives its child chi: the
123
+ # workers of its delegates (they leave the child's process tree,
124
+ # SessionManager.detach_from_spawner) and its tasks (a group of
125
+ # their own each, reparented once the child exits). Found by their
126
+ # records in the sandbox, so nothing outside it: a session's lock
127
+ # held by a group leader, a running task's record whose pid still
128
+ # leads its group and started when the record says (#same_process?). Workers
129
+ # first: one may start a task as it stops.
130
+ def stop_leftovers
131
+ return unless root && File.directory?(root)
132
+
133
+ leftover_workers.each { |pid| stop_group(pid) }
134
+ leftover_tasks.each { |pid| stop_group(pid) }
135
+ end
136
+
80
137
  private
81
138
 
82
139
  def write_config
@@ -92,9 +149,7 @@ module Samagotchi
92
149
  data["bundles"][@manifest.name] ||= {}
93
150
  data["bundles"][@manifest.name].merge!(@bundle_settings)
94
151
  data["bundles"]["cost-cap"]["cap"] = @cost_cap if @cost_cap_override
95
- path = File.join(config_home, "samagotchi", "config.yml")
96
- FileUtils.mkdir_p(File.dirname(path))
97
- File.write(path, YAML.dump(data))
152
+ @layout.write_config(data)
98
153
  end
99
154
 
100
155
  def read_manifest
@@ -121,36 +176,60 @@ module Samagotchi
121
176
  raise Error, "chi bundle try: manifest.yml is not valid YAML (#{e.message})"
122
177
  end
123
178
 
124
- def install_path(path)
125
- out, err, status = Open3.capture3(child_env, RbConfig.ruby, "-I", LIB_DIR, CHI,
126
- "bundle", "install", path, "--force", chdir: work_dir)
127
- return if status.success?
179
+ def leftover_workers
180
+ sessions = File.join(state_home, Session::STATE_SUBDIR)
181
+ Dir.glob(File.join(sessions, "*", OwnerLock::FILE)).filter_map do |lock|
182
+ pid = OwnerLock.owner(File.dirname(lock))&.pid
183
+ pid if ProcessGroup.leader?(pid)
184
+ end
185
+ end
186
+
187
+ def leftover_tasks
188
+ pattern = File.join(work_dir, Tools::TaskRuntime::TASKS_DIR, "*", Tools::TaskRuntime::METADATA_FILENAME)
189
+ Dir.glob(pattern).filter_map do |file|
190
+ record = Tools::TaskRecord.from_file(JSON.parse(File.read(file)))
191
+ record.pid if record&.running? && same_process?(record)
192
+ rescue JSON::ParserError, SystemCallError
193
+ nil
194
+ end
195
+ end
196
+
197
+ # Stricter than TaskRuntime.chis_group?: a record with no start time
198
+ # (a hand-over not adopted yet) or a start time that can't be read
199
+ # now is skipped, never a group whose leader may reuse the pid.
200
+ def same_process?(record)
201
+ started = record.pid_started
202
+ return false unless started && ProcessGroup.signalable?(record.pid)
203
+
204
+ ProcessGroup.start_time(record.pid) == started && ProcessGroup.leader?(record.pid)
205
+ end
128
206
 
129
- message = [out, err].reject(&:empty?).join("\n").strip
130
- raise Error, "chi bundle try: #{message.empty? ? "bundle install failed" : message}"
207
+ def stop_group(pid)
208
+ ProcessGroup.stop(pid, grace: LEFTOVER_GRACE, poll: LEFTOVER_POLL)
209
+ rescue ArgumentError, SystemCallError
210
+ nil
131
211
  end
132
212
 
213
+ # The user's environment (their hosts and keys) minus what no child
214
+ # chi takes from its spawner (ChildEnv), the worker markers, this
215
+ # chi's log and test settings, and the env forms of the config
216
+ # sections the sandbox drops.
133
217
  def build_child_env
134
- env = ENV.to_h
135
- SessionManager::SESSION_MARKER_ENV.each { |key| env.delete(key) }
218
+ env = ChildEnv.scrub(ENV.to_h)
136
219
  env.delete("SAMAGOTCHI_LOG_FILE")
137
220
  env.delete("SAMAGOTCHI_LOG_DISABLE")
138
- env.delete("SAMAGOTCHI_SKIP_SYSTEM_BUNDLE")
221
+ env.delete(MemoryBundle::SystemBundle::SKIP_ENV)
139
222
  env.delete("SAMAGOTCHI_ENV") if env["SAMAGOTCHI_ENV"] == "test"
140
- env.delete("SAMAGOTCHI_INSTALLED_VERSION")
141
223
  env.delete_if do |key, _|
142
- key.start_with?(SessionManager::WORKER_ENV_PREFIX) || key == SessionManager::BOOT_FALLBACK_ENV ||
224
+ key.start_with?(ChildEnv::WORKER_PREFIX) || key == SessionManager::BOOT_FALLBACK_ENV ||
143
225
  CONFIG_DENYLIST_ENV_PREFIXES.any? { |prefix| key == prefix || key.start_with?("#{prefix}_") }
144
226
  end
145
227
  env.merge!(
146
228
  "HOME" => Dir.home,
147
229
  "SAMAGOTCHI_KEYCHAIN" => "on",
148
- "XDG_CONFIG_HOME" => config_home,
149
- "XDG_STATE_HOME" => state_home,
150
- "SAMAGOTCHI_RECAP_ENABLED" => "false",
151
- "SAMAGOTCHI_CACHE_WARMUP" => "off"
230
+ **@layout.env
152
231
  )
153
- env.merge!(InstalledGem.unbundled_env_changes(env)) if InstalledGem.spec
232
+ env.merge!(ChildEnv.unbundled(env))
154
233
  env
155
234
  end
156
235
  end
@@ -2,7 +2,10 @@
2
2
 
3
3
  require "json"
4
4
  require_relative "../../analytics_file"
5
+ require_relative "../../bridge/card_store"
6
+ require_relative "../../session"
5
7
  require_relative "../../session_metrics"
8
+ require_relative "../../tool_response"
6
9
 
7
10
  module Samagotchi
8
11
  class BundleCommand
@@ -15,25 +18,23 @@ module Samagotchi
15
18
 
16
19
  class TraceReader
17
20
  DENIAL_TEXT = "No one to approve it (non-interactive run)."
18
- SESSION_ID = /\A[A-Za-z0-9_-]+\z/
19
21
  STDERR_LIMIT = 4000
20
22
 
21
23
  def initialize(state_home:, session_id:)
22
24
  @state_home = state_home
23
- @session_id = session_id if session_id.to_s.match?(SESSION_ID)
25
+ @session_id = session_id if Session.valid_id?(session_id)
24
26
  end
25
27
 
26
28
  def read(exit_code:, answer:, stderr:, sandbox:, timed_out: false)
27
29
  return empty_trace(exit_code, answer, stderr, sandbox, timed_out) unless @session_id
28
30
 
29
- sessions_dir = File.join(@state_home, "samagotchi", "sessions")
30
- session_path = File.join(sessions_dir, "#{@session_id}.json")
31
- session_dir = File.join(sessions_dir, @session_id)
32
- session = json_file(session_path) || {}
31
+ sessions_dir = File.join(@state_home, Session::STATE_SUBDIR)
32
+ session_dir = Session.session_dir(@session_id, state_dir: sessions_dir)
33
+ session = json_file(Session.session_file(@session_id, state_dir: sessions_dir)) || {}
33
34
  messages = Array(session["messages"]).select { |message| message.is_a?(Hash) }
34
35
  analytics = AnalyticsFile.read(session_dir) || {}
35
36
  entries = card_entries(session_dir)
36
- tool_rows, denied = tools(messages, analytics, timed_out)
37
+ tool_rows, denied = tools(messages, analytics)
37
38
  summary = SessionMetrics.saved_summary(session_dir)
38
39
  cost, estimate = cost_summary(summary)
39
40
  questions = entries.select { |entry| entry["type"] == "question" }
@@ -62,7 +63,11 @@ module Samagotchi
62
63
  timed_out: timed_out)
63
64
  end
64
65
 
65
- def tools(messages, analytics, timed_out)
66
+ # One row per saved call. A turn a --timeout interrupt cut (a
67
+ # Ctrl-C) saves its calls too, the one it cut short answered as
68
+ # interrupted (ToolResponse.interrupted): a record has no row of its
69
+ # own, so no call shows twice.
70
+ def tools(messages, analytics)
66
71
  records = Array(analytics["tool_records"]).select { |record| record.is_a?(Hash) }
67
72
  .to_h do |record|
68
73
  [[record["iteration"].to_i, record["call_index"].to_i], record]
@@ -86,6 +91,7 @@ module Samagotchi
86
91
  is_denied = response && response["content"].to_s.include?(DENIAL_TEXT)
87
92
  denied << index if is_denied
88
93
  status = is_denied ? "denied" : record&.dig("status").to_s
94
+ status = "interrupted" if status.empty? && response && ToolResponse.interrupted_text?(response["content"])
89
95
  status = "unknown" if status.empty?
90
96
  {
91
97
  index: index,
@@ -96,24 +102,9 @@ module Samagotchi
96
102
  }
97
103
  end
98
104
  end
99
- rows += unsaved_rows(records, iteration, index) if timed_out
100
105
  [rows, denied]
101
106
  end
102
107
 
103
- # A turn cut by a --timeout interrupt saves only the messages before
104
- # it (a Ctrl-C), while its tool records stay: the records past the
105
- # last saved model message are its calls, without their arguments.
106
- # Only then: a normal run's loop also counts iterations it saves no
107
- # model message for (a cut's retry, an empty-answer nudge).
108
- def unsaved_rows(records, saved_iterations, index)
109
- records.select { |(iteration, _), _| iteration > saved_iterations }.sort.map do |_, record|
110
- index += 1
111
- status = record["status"].to_s
112
- { index: index, name: record["called_as"] || record["tool"] || "tool", args: "",
113
- status: status.empty? ? "unknown" : status, duration_ms: record["duration_ms"].to_i }
114
- end
115
- end
116
-
117
108
  def call_name(call)
118
109
  function = call["function"].is_a?(Hash) ? call["function"] : {}
119
110
  call["name"] || function["name"] || "tool"
@@ -132,10 +123,13 @@ module Samagotchi
132
123
  args.to_s.gsub(/\s+/, " ").strip[0, 200]
133
124
  end
134
125
 
126
+ # The listing's placement fields: nothing to place in a one-turn run.
127
+ LISTING_FIELDS = %w[turns_since current earlier].freeze
128
+
129
+ # The saved cards as the web reads them (CardStore.saved), with
130
+ # string keys and values, as the JSON trace prints them.
135
131
  def card_entries(session_dir)
136
- data = json_file(File.join(session_dir, "cards.json"))
137
- entries = data.is_a?(Hash) ? data["entries"] : []
138
- Array(entries).select { |entry| entry.is_a?(Hash) }
132
+ JSON.parse(JSON.generate(Bridge::CardStore.saved(session_dir))).map { |entry| entry.except(*LISTING_FIELDS) }
139
133
  end
140
134
 
141
135
  def unanswerable?(question)