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
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "bridge_client"
5
+ require_relative "worker_sidecar"
6
+ require_relative "question_desk"
7
+ require_relative "guardrails/parent_approvals"
8
+ require_relative "log"
9
+
10
+ module Samagotchi
11
+ # A child worker taking its parent's answer to a relayed approval. The
12
+ # POST that says "answered" carries no answer and no authority: this
13
+ # worker asks its parent session's live Bridge what the parent's relay
14
+ # holds (RelayDesk, which only the parent's question flow writes), and
15
+ # answers its own question with that, only when it is about this session
16
+ # and the question pending now, and is answered.
17
+ class RelayVerifier
18
+ # The client id a relayed answer is recorded with: relay:<parent8>.
19
+ CLIENT_PREFIX = "relay"
20
+
21
+ # @param engine [Engine] this (the child's) worker's Engine
22
+ def initialize(engine:, state_dir:, session_id:)
23
+ @engine = engine
24
+ @state_dir = state_dir
25
+ @session_id = session_id
26
+ end
27
+
28
+ # @return [Array(Hash, Integer, Hash)] headers, status, body: 200
29
+ # answered, 409 no longer pending, 403 refused (a parent agent's allow
30
+ # beyond this worker's guardrails.parent_approvals), 422
31
+ # relay_unverified (logged; the question stays open)
32
+ def answer(relay_id:, question_id:)
33
+ parent_id = own_parent_id or return unverified("this session has no parent", relay_id)
34
+ relay = parent_relay(parent_id, relay_id) or return unverified("the parent has no such relay", relay_id)
35
+ return unverified("the relay is about another session", relay_id) unless relay["child_id"] == @session_id
36
+ return unverified("the relay is about another question", relay_id) unless relay["child_question_id"] == question_id
37
+ return unverified("the relay is #{relay["state"]}, not answered", relay_id) unless relay["state"] == "answered"
38
+
39
+ apply(relay, parent_id: parent_id, question_id: question_id, relay_id: relay_id)
40
+ end
41
+
42
+ private
43
+
44
+ def apply(relay, parent_id:, question_id:, relay_id:)
45
+ answer = relay["answer"].is_a?(Hash) ? relay["answer"] : {}
46
+ parent_agent = relay["by"] != "user"
47
+ client_id = "#{CLIENT_PREFIX}:#{parent_id[0, 8]}"
48
+ if answer["dismissed"]
49
+ return not_pending(question_id) unless @engine.cancel_question("dismissed", id: question_id)
50
+
51
+ log("relay_answered", relay_id, id: question_id, dismissed: true, by: relay["by"])
52
+ return [{}, 200, { status: "dismissed", question_id: question_id }]
53
+ end
54
+
55
+ selected = labels(answer["selected_indices"], question_id) or return unverified("the answer's option is out of range", relay_id)
56
+ result = @engine.answer_question(id: question_id, selected: selected, freeform: answer["freeform"],
57
+ client_id: client_id, parent_agent: parent_agent)
58
+ log("relay_answered", relay_id, id: question_id, by: relay["by"])
59
+ [{}, 200, { status: "answered", question_id: question_id, answer: result }]
60
+ rescue QuestionDesk::NotPending
61
+ not_pending(question_id)
62
+ rescue QuestionDesk::Refused => e
63
+ log("relay_refused", relay_id, id: question_id, reason: e.reason)
64
+ [{}, 403, { error: "parent_approval_refused", reason: e.reason.to_s,
65
+ detail: Guardrails::ParentApprovals.message(e.reason) }]
66
+ rescue ArgumentError => e
67
+ unverified("the answer doesn't fit the question: #{e.message}", relay_id)
68
+ end
69
+
70
+ # The parent's option indices as this question's own labels (the
71
+ # parent's are relabelled; the order is the same). nil when one is out
72
+ # of range; [] for a freeform-only deny.
73
+ def labels(indices, question_id)
74
+ pending = @engine.pending_question
75
+ return [] unless pending && pending[:id].to_s == question_id
76
+
77
+ options = Array(pending[:options])
78
+ indices = Array(indices)
79
+ return nil unless indices.all? { |index| index.is_a?(Integer) && index >= 0 && index < options.size }
80
+
81
+ indices.map { |index| options[index] }
82
+ end
83
+
84
+ def own_parent_id
85
+ Session.load(@session_id, state_dir: @state_dir).parent_id
86
+ rescue ArgumentError
87
+ nil
88
+ end
89
+
90
+ # @return [Hash, nil] what the parent's live Bridge says about the relay
91
+ def parent_relay(parent_id, relay_id)
92
+ dir = Session.session_dir(parent_id, state_dir: @state_dir)
93
+ client = BridgeClient.discover(parent_id, session_dir: dir) or return nil
94
+ response = client.relay_status(relay_id)
95
+ response.ok? ? response.json : nil
96
+ rescue SystemCallError, IOError
97
+ nil
98
+ end
99
+
100
+ def not_pending(question_id)
101
+ [{}, 409, { error: "question_not_pending", detail: "no pending question #{question_id}" }]
102
+ end
103
+
104
+ def unverified(why, relay_id)
105
+ log("relay_unverified", relay_id, why: why)
106
+ [{}, 422, { error: "relay_unverified", detail: why }]
107
+ end
108
+
109
+ def log(event, relay_id, **fields)
110
+ Log.info(:bridge, event, sid: @session_id, relay: relay_id.to_s[0, 8], **fields)
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "worker_sidecar"
4
+ require_relative "log"
5
+
6
+ module Samagotchi
7
+ # A child's look at its parent while its question is relayed there: when
8
+ # the parent's worker is gone, its relay card and RelayDesk went with it,
9
+ # so the question's relay mark is cleared (reason parent_gone) and its
10
+ # cards say "answer here" again. Outside the question desk: it clears a
11
+ # mark, never cancels the question. Stops once the question is no longer
12
+ # that relay's.
13
+ module RelayWatcher
14
+ INTERVAL = 2
15
+
16
+ module_function
17
+
18
+ # @param engine [Engine] the child's
19
+ # @param parent_dir [String] the parent session's folder (its sidecar)
20
+ # @param live [#call] whether the parent's worker is up
21
+ # @return [Thread]
22
+ def start(engine:, question_id:, relay_id:, parent_dir:, interval: INTERVAL,
23
+ live: -> { WorkerSidecar.live(parent_dir, unlink: false) })
24
+ Thread.new do
25
+ loop do
26
+ sleep(interval)
27
+ break unless relayed?(engine.pending_question, question_id, relay_id)
28
+ next if live.call
29
+
30
+ Log.info(:bridge, "relay_parent_gone", id: question_id)
31
+ engine.annotate_question(question_id, relayed_to: nil, reason: "parent_gone")
32
+ break
33
+ end
34
+ rescue StandardError => e
35
+ Log.warn(:bridge, "relay_watch_failed", error: e.class.name, message: e.message)
36
+ end.tap { |thread| thread.report_on_exception = false }
37
+ end
38
+
39
+ def relayed?(pending, question_id, relay_id)
40
+ relay = pending && pending[:relayed_to]
41
+ return false unless relay.is_a?(Hash) && pending[:id].to_s == question_id.to_s
42
+
43
+ (relay[:relay_id] || relay["relay_id"]).to_s == relay_id.to_s
44
+ end
45
+ end
46
+ end
@@ -50,10 +50,13 @@ module Samagotchi
50
50
  # a failure replaces the note and leaves the count as it was.
51
51
  # @param owner_grace [Numeric, nil] seconds with no live worker before
52
52
  # :worker_gone (one that died before its rescue leaves it running)
53
+ # @param interject [#call, nil] called once a poll, before the sleep
54
+ # (the delegate wait relays other children's approvals there); the
55
+ # time it takes doesn't count against +timeout+
53
56
  # @return [Result]
54
57
  # @raise [ArgumentError] no such session
55
58
  def call(id, state_dir:, cursor:, timeout: nil, poll_interval: POLL_INTERVAL, cancelled: -> { false },
56
- baseline: nil, owner_grace: nil)
59
+ baseline: nil, owner_grace: nil, interject: nil)
57
60
  deadline = timeout && (monotonic + timeout.to_f)
58
61
  seen_running = false
59
62
  gone_since = nil
@@ -104,6 +107,12 @@ module Samagotchi
104
107
 
105
108
  return Result.new(status: :timeout) if deadline && monotonic > deadline
106
109
 
110
+ if interject
111
+ started = monotonic
112
+ interject.call
113
+ deadline += monotonic - started if deadline
114
+ next if cancelled.call
115
+ end
107
116
  sleep(poll_interval)
108
117
  end
109
118
  end
@@ -7,6 +7,7 @@ require_relative "context_window"
7
7
  require_relative "session"
8
8
  require_relative "log_path"
9
9
  require_relative "model_profile"
10
+ require_relative "model_overlay"
10
11
  require_relative "thinking"
11
12
  require_relative "served_model"
12
13
  require_relative "host_registry"
@@ -39,7 +40,8 @@ module Samagotchi
39
40
  end
40
41
 
41
42
  def fields(env: ENV)
42
- model = model_name
43
+ default = model_name
44
+ model = session_model(env) || default
43
45
  [
44
46
  ["version", "#{VERSION} (ruby #{RUBY_VERSION})"],
45
47
  ["source", "#{SOURCE_DIR} (#{install_kind})"],
@@ -49,7 +51,8 @@ module Samagotchi
49
51
  ["project memories", Tools::MemoryRead.memories_dir("project", env: env)],
50
52
  ["sessions", Session.default_state_dir(env: env)],
51
53
  ["log", log_path(env)],
52
- ["model", model || "(not configured)"],
54
+ ["model", model ? model_label(model, default, env) : "(not configured)"],
55
+ ["model key", model ? model_key_for(model, env) : "-"],
53
56
  ["host", model ? host_for(model, env) : "-"],
54
57
  ["api key", model ? api_key_for(model, env) : "-"],
55
58
  ["loop", model ? loop_for(model, env) : "-"],
@@ -125,6 +128,46 @@ module Samagotchi
125
128
  nil
126
129
  end
127
130
 
131
+ SESSION_MODEL_ENV = "SAMAGOTCHI_SESSION_MODEL"
132
+ PARENT_SESSION_ENV = "SAMAGOTCHI_PARENT_SESSION"
133
+
134
+ # The model of the session this chi self runs in (its execute exports
135
+ # SAMAGOTCHI_SESSION_MODEL, the resolved ref, live after /model); nil
136
+ # outside a session's commands.
137
+ def session_model(env)
138
+ name = env[SESSION_MODEL_ENV].to_s.strip
139
+ name.empty? ? nil : name
140
+ end
141
+
142
+ # "splash:x (this session abcd1234; default main:y)", "main:y (this
143
+ # session abcd1234, the default)", or outside a session "main:y (default)".
144
+ def model_label(model, default, env)
145
+ return "#{model} (default)" unless session_model(env)
146
+
147
+ id = env[PARENT_SESSION_ENV].to_s
148
+ session = id.empty? || id == "chi" ? "this session" : "this session #{id[0, 8]}"
149
+ return "#{model} (#{session}, the default)" if default && same_model?(model, default, env)
150
+
151
+ "#{model} (#{session}; default #{default || "not configured"})"
152
+ end
153
+
154
+ def same_model?(one, other, env)
155
+ return true if one == other
156
+
157
+ ConfigFile.model_ref(one, env: env).ref == ConfigFile.model_ref(other, env: env).ref
158
+ rescue StandardError
159
+ false
160
+ end
161
+
162
+ # The memory overlay key (ModelOverlay.key_for the bare id, as the
163
+ # engine keys memory_write current_model_only): which `<name>.<key>.md`
164
+ # overlays are this model's.
165
+ def model_key_for(model, env)
166
+ ModelOverlay.key_for(HostRegistry.new(env: env).bare_name(model)) || "-"
167
+ rescue StandardError
168
+ ModelOverlay.key_for(model) || "-"
169
+ end
170
+
128
171
  def model_name
129
172
  ModelProfile.required_model_name
130
173
  rescue ArgumentError
@@ -147,6 +190,8 @@ module Samagotchi
147
190
  entry.chat? ? "chat (api: openai)" : "native (raw prompt)"
148
191
  end
149
192
 
193
+ NO_PROPS = "reported per turn (the server has no /props)"
194
+
150
195
  # chi self's one server call: a single-model llama.cpp answers any name
151
196
  # with the model it loaded, and its /props names it (model_alias). One
152
197
  # GET with the probe's short timeouts; a remote host isn't asked.
@@ -157,10 +202,11 @@ module Samagotchi
157
202
  bare = target.bare_model
158
203
  return "-" unless entry
159
204
  return "reported per turn (remote host)" if entry.remote?
205
+ return NO_PROPS if entry.chat?
160
206
 
161
207
  client = registry.client_for(entry)
162
208
  props = client.server_props(model: bare)
163
- return "reported per turn (the server has no /props)" if props.nil?
209
+ return NO_PROPS if props.nil? || props.status == :http_error
164
210
  return "unknown (the server didn't answer; is it running?)" if props.status == :network_error
165
211
 
166
212
  served = ServedModel.from_props(props)
@@ -37,6 +37,8 @@ module Samagotchi
37
37
  chi send --wait [--timeout S] [--format json] ID
38
38
  Sends a message to each session, as if typed in it: a turn starts,
39
39
  or a running one picks it up. A stopped session's worker starts.
40
+ A session command (/model x, !cmd) runs as the command; --wait
41
+ then has no reply to wait for (exit 0, status command).
40
42
  -m TEXT the message; stdin, when piped too, goes above it as a
41
43
  quote (context); without -m, stdin is the message
42
44
  --image PATH
@@ -64,7 +66,7 @@ module Samagotchi
64
66
  (--wait) one JSON object on stdout, whatever the end:
65
67
  status answered (text), question (question,
66
68
  answer_with), running, or failed, canceled,
67
- no_answer, error, worker_gone, stopped (detail)
69
+ no_answer, error, worker_gone, stopped, command (detail)
68
70
  Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
69
71
  question waits, 4 still running (--timeout), 130 Ctrl-C.
70
72
  Only sessions on this machine. Answers show in the attached TUI
@@ -246,23 +248,27 @@ module Samagotchi
246
248
  error_line("chi send: refused: #{refusal}")
247
249
  return 1
248
250
  end
251
+ # With images the web's way: idle (the message names it in the
252
+ # lists), the images copied in, then the message as a turn. So too a
253
+ # line that may be a session command (/model x): its worker's answer
254
+ # says whether it ran as one.
255
+ through_worker = images || command_like?(prompt)
249
256
  begin
250
- # With images the web's way: idle (the message names it in the
251
- # lists), the images copied in, then the message as a turn.
252
- start = images ? { prompt: nil, title: prompt } : { prompt: prompt }
257
+ start = through_worker ? { prompt: nil, title: prompt } : { prompt: prompt }
253
258
  session = SessionManager.spawn_session(**start, working_directory: options[:dir],
254
259
  model_name: options[:model], state_dir: @state_dir)
255
260
  rescue StandardError => e
256
261
  error_line("chi send: could not start a session: #{e.message}")
257
262
  return 1
258
263
  end
259
- if images
264
+ if through_worker
260
265
  return 1 unless deliver_new(session, prompt)
261
266
  else
262
267
  @info.puts("#{session.id} started")
263
268
  end
264
269
  @session_id = session.id
265
270
  return 0 unless options[:wait]
271
+ return report_command(session.id) if @sent_command
266
272
 
267
273
  wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
268
274
  timeout: options[:timeout])
@@ -297,6 +303,7 @@ module Samagotchi
297
303
  cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
298
304
  baseline = baseline_of(session)
299
305
  return 1 unless deliver(id, prompt)
306
+ return report_command(id) if @sent_command
300
307
 
301
308
  wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout])
302
309
  end
@@ -352,10 +359,36 @@ module Samagotchi
352
359
  return false
353
360
  end
354
361
 
355
- @info.puts("#{session.id} started#{with_images}")
362
+ @sent_command = command_ack?(result[:ack])
363
+ @info.puts("#{session.id} started#{with_images}#{"; #{SENT_COMMAND}" if @sent_command}")
356
364
  true
357
365
  end
358
366
 
367
+ SENT_COMMAND = "sent as a session command"
368
+
369
+ # Whether the worker took the message as a session command (a POST
370
+ # /turn whose line is one answers with its command_id).
371
+ def command_ack?(ack) = ack.is_a?(Hash) && !ack["command_id"].to_s.empty?
372
+
373
+ # Whether +text+ may be a session command, which only its worker knows
374
+ # (a bundle's too).
375
+ def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
376
+
377
+ # --wait after a message that ran as a session command: no reply comes
378
+ # for it. Its output shows where the session is open.
379
+ # @return [Integer] 0
380
+ def report_command(id)
381
+ detail = "#{SENT_COMMAND}: no reply to wait for (its output shows in chi --attach #{id} or the web)"
382
+ if @json
383
+ @stdout.puts(JSON.generate({ status: "command", session_id: id, detail: detail }))
384
+ @stdout.flush
385
+ @reported = true
386
+ else
387
+ @stderr.puts("#{id[0, 8]} #{detail}")
388
+ end
389
+ CLI::Exit::OK
390
+ end
391
+
359
392
  # @return [Boolean] whether the message was queued
360
393
  def deliver(id, prompt)
361
394
  short = id[0, 8]
@@ -373,6 +406,12 @@ module Samagotchi
373
406
  return false
374
407
  end
375
408
 
409
+ @sent_command = command_ack?(result[:ack])
410
+ if @sent_command
411
+ @info.puts("#{short} #{SENT_COMMAND}")
412
+ return true
413
+ end
414
+
376
415
  # A busy worker runs a message with images as its own next turn
377
416
  # rather than merging it into the running one.
378
417
  note = if owner.nil? then " (started its worker)"
@@ -111,7 +111,9 @@ module Samagotchi
111
111
  return nil unless live && pending.is_a?(Hash) && pending[:id]
112
112
 
113
113
  kind = pending[:kind].to_s
114
- { id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question" }
114
+ relayed = pending[:relayed_to]
115
+ relayed_to = relayed.is_a?(Hash) ? (relayed[:parent_short] || relayed["parent_short"]) : nil
116
+ { id: pending[:id], kind: %w[approval hook].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
115
117
  end
116
118
 
117
119
  # The stored project root, else (a file from before the field) the
@@ -464,7 +466,18 @@ module Samagotchi
464
466
  entry.is_a?(Hash) ? entry.each_with_object({}) { |(k, v), h| h[k.to_sym] = v } : entry
465
467
  end
466
468
  end
467
- message
469
+ symbolize_empty_answer(message)
470
+ end
471
+
472
+ # An empty-answer note's marker (TurnNote.empty) and its steps, as
473
+ # they were saved.
474
+ def symbolize_empty_answer(message)
475
+ marker = message[:empty_answer]
476
+ return message unless marker.is_a?(Hash)
477
+
478
+ marker = marker.transform_keys(&:to_sym)
479
+ marker[:steps] = marker[:steps].map { |step| step.is_a?(Hash) ? step.transform_keys(&:to_sym) : step } if marker[:steps].is_a?(Array)
480
+ message.merge(empty_answer: marker)
468
481
  end
469
482
 
470
483
  def session_path(session_id, state_dir:)
@@ -241,7 +241,8 @@ module Samagotchi
241
241
  # a turn running, recap = the saved recap's first sentence, project =
242
242
  # Session#project_root, waiting = the kind of question it waits on
243
243
  # ("question", "approval", "hook"; Session#waiting_question) or nil,
244
- # waiting_id = that question's id or nil
244
+ # waiting_id = that question's id or nil, relayed_to = the parent
245
+ # whose card it waits in too (the approval relay), its short id, or nil
245
246
  def self.session_summaries(live: false, cwd: nil, limit: nil, include_tests: true, exclude: nil, state_dir: nil,
246
247
  project_root: nil, include_archived: false, sort: nil, order: nil)
247
248
  sd = state_dir || Session.default_state_dir
@@ -269,7 +270,7 @@ module Samagotchi
269
270
  # waiting: the question's kind, waiting_id: its id (what chi answer
270
271
  # --question takes); both nil with none.
271
272
  def self.waiting_fields(waiting)
272
- { waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id) }
273
+ { waiting: waiting&.dig(:kind), waiting_id: waiting&.dig(:id), relayed_to: waiting&.dig(:relayed_to) }
273
274
  end
274
275
  private_class_method :waiting_fields
275
276