samagotchi 0.92.0 → 0.94.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -1
  3. data/README.md +1 -1
  4. data/bin/chi +4 -4
  5. data/docs/architecture.md +4 -4
  6. data/docs/cli.md +1 -1
  7. data/docs/guardrails.md +4 -5
  8. data/docs/hooks.md +1 -1
  9. data/docs/internals/bundles.md +1 -1
  10. data/docs/internals/engine.md +6 -0
  11. data/docs/memory.md +26 -1
  12. data/docs/plugins.md +37 -0
  13. data/docs/releasing.md +2 -2
  14. data/docs/sessions.md +1 -1
  15. data/docs/testing.md +11 -2
  16. data/lib/samagotchi/bootstrap/probe.rb +2 -2
  17. data/lib/samagotchi/bootstrap_command.rb +1 -0
  18. data/lib/samagotchi/bundle_command.rb +1 -0
  19. data/lib/samagotchi/bundles/dev/manifest.yml +2 -1
  20. data/lib/samagotchi/bundles/model-notes/manifest.yml +11 -0
  21. data/lib/samagotchi/bundles/model-notes/model_notes_deepseek.md +7 -0
  22. data/lib/samagotchi/bundles/model-notes/notes/README.md +37 -0
  23. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  24. data/lib/samagotchi/child_reports.rb +12 -14
  25. data/lib/samagotchi/client_id.rb +18 -0
  26. data/lib/samagotchi/config/restart_watch.rb +112 -0
  27. data/lib/samagotchi/config.rb +18 -59
  28. data/lib/samagotchi/engine.rb +4 -14
  29. data/lib/samagotchi/guardrails/data_readers.rb +34 -30
  30. data/lib/samagotchi/guardrails/parent_levels.rb +31 -0
  31. data/lib/samagotchi/guardrails/parent_policy.rb +9 -20
  32. data/lib/samagotchi/guardrails/shell_lex.rb +28 -7
  33. data/lib/samagotchi/hook_question.rb +21 -0
  34. data/lib/samagotchi/hook_routes.rb +6 -6
  35. data/lib/samagotchi/hooks/registry.rb +5 -5
  36. data/lib/samagotchi/host_registry.rb +1 -1
  37. data/lib/samagotchi/llm/anthropic_messages.rb +5 -0
  38. data/lib/samagotchi/memory_bundle/profile.rb +22 -4
  39. data/lib/samagotchi/memory_bundle/shipped_update.rb +5 -1
  40. data/lib/samagotchi/parent_answers_command.rb +6 -6
  41. data/lib/samagotchi/plugin/context.rb +6 -5
  42. data/lib/samagotchi/plugin/http_transport.rb +14 -3
  43. data/lib/samagotchi/plugin/sessions.rb +2 -3
  44. data/lib/samagotchi/question_desk.rb +12 -9
  45. data/lib/samagotchi/reply_wait/poll.rb +267 -0
  46. data/lib/samagotchi/reply_wait.rb +47 -190
  47. data/lib/samagotchi/send_command/parent_gate.rb +83 -0
  48. data/lib/samagotchi/send_command/wait_target.rb +55 -0
  49. data/lib/samagotchi/send_command.rb +31 -100
  50. data/lib/samagotchi/session.rb +10 -9
  51. data/lib/samagotchi/session_inbox.rb +31 -8
  52. data/lib/samagotchi/session_manager.rb +48 -15
  53. data/lib/samagotchi/session_metrics.rb +22 -3
  54. data/lib/samagotchi/sessions_command.rb +4 -4
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -3
  56. data/lib/samagotchi/terminal_ui/formatting.rb +8 -5
  57. data/lib/samagotchi/testing/bundle.rb +1 -1
  58. data/lib/samagotchi/testing/host.rb +2 -8
  59. data/lib/samagotchi/testing/recorder.rb +6 -9
  60. data/lib/samagotchi/testing/sandbox.rb +26 -0
  61. data/lib/samagotchi/tools/delegate.rb +10 -10
  62. data/lib/samagotchi/tools/delegate_cursor.rb +32 -8
  63. data/lib/samagotchi/tools/delegate_relay.rb +31 -16
  64. data/lib/samagotchi/tools/delegate_wait.rb +4 -4
  65. data/lib/samagotchi/update_command.rb +1 -1
  66. data/lib/samagotchi/version.rb +1 -1
  67. data/lib/samagotchi/web/app.rb +46 -63
  68. data/lib/samagotchi/web/public/app.js +137 -638
  69. data/lib/samagotchi/web/public/dock_reserve.js +103 -0
  70. data/lib/samagotchi/web/public/index.html +4 -4
  71. data/lib/samagotchi/web/public/send_turn.js +173 -0
  72. data/lib/samagotchi/web/public/session_actions.js +203 -0
  73. data/lib/samagotchi/web/public/start_page.js +236 -0
  74. data/lib/samagotchi/web/public/toast_place.js +1 -1
  75. metadata +14 -1
@@ -15,6 +15,7 @@ require_relative "prompt_note"
15
15
 
16
16
  require_relative "paths"
17
17
  require_relative "project_scope"
18
+ require_relative "guardrails/parent_levels"
18
19
 
19
20
  module Samagotchi
20
21
  # archive_store requires this file.
@@ -228,19 +229,19 @@ module Samagotchi
228
229
  # it (SessionSetup.of), a delegate child takes its parent's.
229
230
  attr_reader :parent_answers
230
231
 
231
- # @param value [String, Symbol, nil] stripped and downcased; "" and
232
- # "default" are none; a value that isn't a level is kept as written
232
+ # @param value [String, Symbol, nil] read as
233
+ # Guardrails::ParentPolicy.parse_level: a level, or nil for none ("",
234
+ # "default"); a value that isn't a level is kept as written, stripped
233
235
  # (read as hard: ParentPolicy.session_level)
234
236
  def parent_answers=(value)
235
- word = value.to_s.strip
236
- word = word.downcase if PARENT_ANSWERS_WORDS.include?(word.downcase)
237
- @parent_answers = word.empty? || word == "default" ? nil : word
237
+ level = Guardrails::ParentPolicy.parse_level(value)
238
+ @parent_answers = case level
239
+ when :unset then nil
240
+ when :invalid then value.to_s.strip
241
+ else level
242
+ end
238
243
  end
239
244
 
240
- # The levels and "default" (Guardrails::ParentPolicy::ORDER and UNSET;
241
- # not required here: that file loads the guardrails).
242
- PARENT_ANSWERS_WORDS = %w[hard standard soft default].freeze
243
-
244
245
  # The hook that stopped the last turn (stop_turn, or a cut with no retry
245
246
  # left), e.g. "loop-guard"; nil when it ended otherwise.
246
247
  def stopped_by
@@ -289,8 +289,11 @@ module Samagotchi
289
289
  # turn takes it (the Bridge's POST turn/dequeue, under the event log):
290
290
  # only one that waits for the turn's end (a queue or an image message,
291
291
  # #waits_for_turn_end?); a next-step file may be merged at any boundary.
292
- # The file is claimed first, as the drain does (the rename is the race's
293
- # one winner), then deleted, and its tombstone written.
292
+ # Its tombstone is written first, then the file claimed as the drain
293
+ # does (the rename is the race's one winner) and deleted; a claim a
294
+ # turn won takes the tombstone back. Tombstone first: a ReplyWait look
295
+ # that finds the file claimed and no tombstone knows a turn took it
296
+ # (never a removal half done, which it would read as a merge).
294
297
  # @return [Array(Symbol, Input)] [:removed, the input it held],
295
298
  # [:not_queued, nil] for a next-step file, [:claimed, nil] when a
296
299
  # turn took it (its *.json.processing is there), [:none, nil] for
@@ -304,10 +307,24 @@ module Samagotchi
304
307
  next unless data.is_a?(Hash) && data["enqueued_id"] == id
305
308
  return [:not_queued, nil] unless Array(data["images"]).any? || Delivery.queue?(data["delivery"])
306
309
 
307
- claimed = claim_input_file(file) or return [:claimed, nil]
308
- input = read_input(claimed)
309
310
  mark_removed(session_dir, id)
310
- FileUtils.rm_f(claimed)
311
+ unless (claimed = claim_input_file(file))
312
+ unmark_removed(session_dir, id)
313
+ return [:claimed, nil]
314
+ end
315
+ # The claimed file goes whatever the read does: a claimed file left
316
+ # with the tombstone would read as a removal still under way
317
+ # (ReplyWait) until the wait's timeout. One that can't be read was
318
+ # still taken back (no prompt to give). A process killed between
319
+ # the claim and this delete leaves both: that wait then times out,
320
+ # never a wrong answer (a stale *.json.processing is never run).
321
+ input = begin
322
+ read_input(claimed)
323
+ rescue SystemCallError
324
+ UNREADABLE_INPUT
325
+ ensure
326
+ FileUtils.rm_f(claimed)
327
+ end
311
328
  return [:removed, input]
312
329
  rescue JSON::ParserError, SystemCallError
313
330
  next
@@ -316,7 +333,7 @@ module Samagotchi
316
333
  end
317
334
 
318
335
  # Whether a claimed input file (*.json.processing: a turn's, now or
319
- # left by a crash) carries +id+.
336
+ # left by a crash, or a removal's before it deletes it) carries +id+.
320
337
  def self.claimed_input?(session_dir, id)
321
338
  Dir.glob(File.join(session_dir, INPUT_DIR, "*.json.processing")).any? do |file|
322
339
  data = JSON.parse(File.read(file))
@@ -325,7 +342,6 @@ module Samagotchi
325
342
  false
326
343
  end
327
344
  end
328
- private_class_method :claimed_input?
329
345
 
330
346
  def self.mark_removed(session_dir, id)
331
347
  return unless ENQUEUED_ID_NAME.match?(id)
@@ -338,8 +354,15 @@ module Samagotchi
338
354
  end
339
355
  private_class_method :mark_removed
340
356
 
357
+ def self.unmark_removed(session_dir, id)
358
+ FileUtils.rm_f(File.join(session_dir, INPUT_DIR, REMOVED_DIR, id)) if ENQUEUED_ID_NAME.match?(id)
359
+ end
360
+ private_class_method :unmark_removed
361
+
341
362
  # @return [Boolean] whether the queued input +enqueued_id+ was removed
342
- # before it ran (#take_queued_input's tombstone)
363
+ # before it ran (#take_queued_input's tombstone), or is being removed:
364
+ # the tombstone goes first, so while its claimed file is still there
365
+ # (#claimed_input?) the removal may yet lose to a turn's claim
343
366
  def self.removed?(session_dir, enqueued_id)
344
367
  id = enqueued_id.to_s
345
368
  ENQUEUED_ID_NAME.match?(id) && File.exist?(File.join(session_dir, INPUT_DIR, REMOVED_DIR, id))
@@ -80,7 +80,7 @@ module Samagotchi
80
80
 
81
81
  def initialize(session_id)
82
82
  @session_id = session_id
83
- super("session #{session_id} is owned by an interactive TUI")
83
+ super("session #{session_id} is open in a chi REPL; close it there first")
84
84
  end
85
85
  end
86
86
 
@@ -1140,6 +1140,39 @@ module Samagotchi
1140
1140
  # How long a turn waits for the Bridge of a worker a resume just spawned.
1141
1141
  TURN_BRIDGE_WAIT = 5.0
1142
1142
 
1143
+ # What #deliver_turn did with a message.
1144
+ # +status+ :accepted; :refused (the Bridge refused it, or a queue would
1145
+ # reach a worker that doesn't queue); :timeout (the Bridge took it and
1146
+ # never answered); :failed (the input file couldn't be written).
1147
+ # +via+ :bridge (the worker's Bridge answered) or :file (the input-file
1148
+ # fallback); nil when nothing went in.
1149
+ # +ack+ the Bridge's reply body, or the file's {"status", "enqueued_id",
1150
+ # "session_id"}: string keys either way, {} for none.
1151
+ # +code+ a refusal's HTTP status.
1152
+ DeliverResult = Data.define(:status, :via, :ack, :code) do
1153
+ def initialize(status:, via: nil, ack: {}, code: nil) = super(status:, via:, ack: ack || {}, code:)
1154
+
1155
+ def accepted? = status == :accepted
1156
+
1157
+ # Sent as an input file (no Bridge): its turn and outcome carry the
1158
+ # file's enqueued_id, which a wait follows (chi send --wait).
1159
+ def file? = via == :file
1160
+
1161
+ # @return [String, nil] the id the message's turn will carry
1162
+ def enqueued_id
1163
+ id = ack["enqueued_id"].to_s
1164
+ id.empty? ? nil : id
1165
+ end
1166
+
1167
+ # Whether the worker took the line as a session command (a POST /turn
1168
+ # answered with its command_id).
1169
+ def command? = !ack["command_id"].to_s.empty?
1170
+
1171
+ def error = ack["error"]
1172
+
1173
+ def detail = ack["detail"]
1174
+ end
1175
+
1143
1176
  # Hand a user message to the session the way a UI does, for the web
1144
1177
  # composer and `chi send` alike: wakes the worker when none runs, posts
1145
1178
  # through its Bridge so every live UI sees :turn_enqueued, and falls back
@@ -1153,14 +1186,12 @@ module Samagotchi
1153
1186
  # stand-in (the web's specs); #session_owner is optional
1154
1187
  # @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
1155
1188
  # the session's sidecar, waiting TURN_BRIDGE_WAIT for a new worker's
1156
- # @return [Hash] {status: :accepted, ack: Hash} (the Bridge's reply, or
1157
- # {status:, enqueued_id:, session_id:} for a file), {status: :refused,
1158
- # code:, ack:} when the Bridge refused the request (any 4xx with an
1189
+ # @return [DeliverResult] :accepted (via the Bridge or a file),
1190
+ # :refused when the Bridge refused the request (any 4xx with an
1159
1191
  # error: bad images, a delivery it doesn't run) or a queue would reach
1160
- # a worker that doesn't queue (no file then: it would steer),
1161
- # {status: :timeout, ack:} when it took the request but never answered
1162
- # (no file then), or {status: :failed} when the input file couldn't be
1163
- # written
1192
+ # a worker that doesn't queue (no file then: it would steer), :timeout
1193
+ # when it took the request but never answered (no file then), or
1194
+ # :failed when the input file couldn't be written
1164
1195
  # @raise [OwnedByTUI] a chi REPL owns the session
1165
1196
  # @raise [ArgumentError] no such session
1166
1197
  def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, state_dir: nil, manager: self,
@@ -1180,7 +1211,7 @@ module Samagotchi
1180
1211
  options[:delivery] = delivery unless Delivery.next_step?(delivery)
1181
1212
  reply = client.post_turn(**options)
1182
1213
  ack = reply.json
1183
- return { status: :accepted, ack: ack } if reply.status == 202 && ack.is_a?(Hash)
1214
+ return DeliverResult.new(status: :accepted, via: :bridge, ack: ack) if reply.status == 202 && ack.is_a?(Hash)
1184
1215
  # Read after its deadline and dropped: a file would run it after all.
1185
1216
  return turn_timeout if ack.is_a?(Hash) && ack["error"] == "deadline_passed"
1186
1217
  # Any other 4xx with an error is a refusal (bad images, a delivery
@@ -1189,7 +1220,7 @@ module Samagotchi
1189
1220
  # limit, a big piped chi send) and a 5xx keep the file fallback
1190
1221
  # below, which carries the delivery (a queue too).
1191
1222
  if (400..499).cover?(reply.status) && reply.status != 413 && ack.is_a?(Hash) && ack["error"]
1192
- return { status: :refused, code: reply.status, ack: ack }
1223
+ return DeliverResult.new(status: :refused, via: :bridge, code: reply.status, ack: ack)
1193
1224
  end
1194
1225
  rescue Errno::ETIMEDOUT
1195
1226
  # The Bridge drops a turn it reads after the request's deadline
@@ -1205,7 +1236,7 @@ module Samagotchi
1205
1236
  input[:images] = images unless images.empty?
1206
1237
  input[:delivery] = delivery unless Delivery.next_step?(delivery)
1207
1238
  path = manager.write_turn_input(session_id, **input)
1208
- return { status: :failed } unless path
1239
+ return DeliverResult.new(status: :failed) unless path
1209
1240
 
1210
1241
  owner = delivery_owner(manager, session_id, state_dir)
1211
1242
  # A TUI that took the session between the resume and the write never
@@ -1218,7 +1249,8 @@ module Samagotchi
1218
1249
  # wake a new one. (The exiting worker also looks for input it left;
1219
1250
  # one the first resume spawned that is still starting is left be.)
1220
1251
  manager.resume_session(session_id, state_dir: state_dir) if owner.nil?
1221
- { status: :accepted, ack: { status: "accepted", enqueued_id: enqueued_id, session_id: session_id } }
1252
+ DeliverResult.new(status: :accepted, via: :file,
1253
+ ack: { "status" => "accepted", "enqueued_id" => enqueued_id, "session_id" => session_id })
1222
1254
  end
1223
1255
 
1224
1256
  # Whether the session's live worker runs delivery: queue (its sidecar
@@ -1230,12 +1262,13 @@ module Samagotchi
1230
1262
  end
1231
1263
 
1232
1264
  private_class_method def self.queue_unavailable
1233
- { status: :refused, code: 409, ack: Delivery::QUEUE_UNAVAILABLE }
1265
+ DeliverResult.new(status: :refused, code: 409, ack: Delivery::QUEUE_UNAVAILABLE)
1234
1266
  end
1235
1267
 
1236
1268
  private_class_method def self.turn_timeout
1237
- { status: :timeout, ack: { "error" => "worker_timeout",
1238
- "detail" => "the session's worker did not answer, so the message was not sent" } }
1269
+ DeliverResult.new(status: :timeout, via: :bridge,
1270
+ ack: { "error" => "worker_timeout",
1271
+ "detail" => "the session's worker did not answer, so the message was not sent" })
1239
1272
  end
1240
1273
 
1241
1274
  private_class_method def self.delivery_owner(manager, session_id, state_dir)
@@ -7,6 +7,7 @@ require "fileutils"
7
7
  require "securerandom"
8
8
  require_relative "analytics_file"
9
9
  require_relative "atomic_file"
10
+ require_relative "log"
10
11
  require_relative "token_usage"
11
12
  require_relative "model_price"
12
13
  require_relative "session"
@@ -278,6 +279,8 @@ module Samagotchi
278
279
  # in snapshot order. Taken before @mutex, never under it: the write's
279
280
  # IO doesn't hold up the events.
280
281
  @persist_mutex = Mutex.new
282
+ # The error classes #persist has logged (under @persist_mutex).
283
+ @persist_failures = Set.new
281
284
  @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
282
285
  @wall_clock = wall_clock || -> { Time.now }
283
286
  @session_id = nil
@@ -481,7 +484,8 @@ module Samagotchi
481
484
 
482
485
  # Persist the summary snapshot, with every record, to a sibling
483
486
  # analytics.json in the session directory. Atomic write; failures are
484
- # swallowed (analytics must never break the running session).
487
+ # swallowed (analytics must never break the running session), each
488
+ # error class logged once (WARN turn analytics_persist_failed).
485
489
  # @param state_dir [String, nil]
486
490
  # @param running_turn [Boolean] also save the running turn's record so
487
491
  # far (a worker's turn checkpoint): as the turn a worker killed now
@@ -490,13 +494,28 @@ module Samagotchi
490
494
  # turn's own end writes over it. Only the file has it.
491
495
  # @return [Boolean] true on success
492
496
  def persist(state_dir: nil, running_turn: false)
493
- @persist_mutex.synchronize { persist_now(state_dir, running_turn) }
494
- rescue StandardError
497
+ @persist_mutex.synchronize do
498
+ persist_now(state_dir, running_turn)
499
+ rescue StandardError => e
500
+ note_persist_failure(e)
501
+ false
502
+ end
503
+ rescue StandardError # a ThreadError from the mutex, a log that raised: still never raises
495
504
  false
496
505
  end
497
506
 
498
507
  private
499
508
 
509
+ # Logs a #persist failure the first time its error class is seen (under
510
+ # the persist mutex): side requests and checkpoints persist too, so a
511
+ # lasting failure (a full disk, a read-only dir) would log on every one.
512
+ def note_persist_failure(error)
513
+ return if @persist_failures.include?(error.class)
514
+
515
+ @persist_failures << error.class
516
+ Log.warn(:turn, "analytics_persist_failed", error: error.class.name, msg: error.message.to_s[0, 300])
517
+ end
518
+
500
519
  # #persist's work, under the persist mutex.
501
520
  def persist_now(state_dir, running_turn)
502
521
  sid, data, turn_count = @mutex.synchronize do
@@ -365,9 +365,9 @@ module Samagotchi
365
365
  begin
366
366
  id = Samagotchi::Session.resolve_id(given)
367
367
  released = Samagotchi::SessionManager.stop_session(id, wait: 10)
368
- rescue Samagotchi::SessionManager::OwnedByTUI
368
+ rescue Samagotchi::SessionManager::OwnedByTUI => e
369
369
  @stdout.flush
370
- @stderr.puts "session #{id} is open in a chi REPL; close it there first"
370
+ @stderr.puts e.message
371
371
  next false
372
372
  rescue ArgumentError => e
373
373
  @stdout.flush
@@ -394,9 +394,9 @@ module Samagotchi
394
394
  begin
395
395
  id = Samagotchi::Session.resolve_id(given)
396
396
  result = Samagotchi::SessionManager.restart_session(id)
397
- rescue Samagotchi::SessionManager::OwnedByTUI
397
+ rescue Samagotchi::SessionManager::OwnedByTUI => e
398
398
  @stdout.flush
399
- @stderr.puts "session #{id} is open in a chi REPL; close it there first"
399
+ @stderr.puts e.message
400
400
  next false
401
401
  rescue Samagotchi::SessionManager::RestartRefused => e
402
402
  @stdout.flush
@@ -1639,9 +1639,9 @@ module Samagotchi
1639
1639
  end
1640
1640
 
1641
1641
  def reminder_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::SYSTEM_PREFIX)
1642
- def report_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::CHILD_PREFIX)
1643
- def context_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::CONTEXT_PREFIX)
1644
- def task_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::TASK_PREFIX)
1642
+ def report_origin?(origin) = ClientId.origin(origin[:client_id]) == "delegate_report"
1643
+ def context_origin?(origin) = ClientId.origin(origin[:client_id]) == "context"
1644
+ def task_origin?(origin) = ClientId.origin(origin[:client_id]) == "task"
1645
1645
  # A turn with no prompt: "(continuing)"; a context wake's or a task
1646
1646
  # finish wake's label alone.
1647
1647
  def continuing_text(origin) = context_origin?(origin) || task_origin?(origin) ? nil : "(continuing)"
@@ -215,11 +215,14 @@ module Samagotchi
215
215
  return fallback unless client_id
216
216
 
217
217
  id = client_id.to_s
218
- name = id.delete_prefix(ClientId::CONTEXT_PREFIX)
219
- return "context #{name} changed" if id.start_with?(ClientId::CONTEXT_PREFIX) && !name.empty?
220
-
221
- task = id.delete_prefix(ClientId::TASK_PREFIX)
222
- return "task #{Tools::TaskRecord.short_id(task)} finished" if id.start_with?(ClientId::TASK_PREFIX) && !task.empty?
218
+ case ClientId.origin(id)
219
+ when "context"
220
+ name = id.delete_prefix(ClientId::CONTEXT_PREFIX)
221
+ return "context #{name} changed" unless name.empty?
222
+ when "task"
223
+ task = id.delete_prefix(ClientId::TASK_PREFIX)
224
+ return "task #{Tools::TaskRecord.short_id(task)} finished" unless task.empty?
225
+ end
223
226
 
224
227
  prefix = id[/\A[^:]*:/]
225
228
  CLIENT_LABELS[id] || (prefix && CLIENT_LABELS[prefix]) || AUTOMATIC_LABEL
@@ -339,7 +339,7 @@ module Samagotchi
339
339
  def model_answers = @recorder.model_answers
340
340
 
341
341
  # Queue ask_user answers ({selected:, freeform:, selected_indices:},
342
- # or a callable given the Question).
342
+ # or a callable given the HookQuestion).
343
343
  def answer(*answers)
344
344
  @recorder.answers.concat(answers)
345
345
  self
@@ -27,10 +27,7 @@ module Samagotchi
27
27
  messages: -> { recorder[:messages] },
28
28
  messages_partial: -> { recorder[:messages_partial] },
29
29
  notify: ->(text, level, label, fallback_for: nil) { recorder.notify(text, level, label, fallback_for) },
30
- ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
31
- recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
32
- effects: effects)
33
- },
30
+ ask_user: ->(question) { recorder.ask_user(question) },
34
31
  cancelled: -> { recorder[:cancelled] },
35
32
  card: ->(**card) { recorder.card(**card) },
36
33
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
@@ -89,10 +86,7 @@ module Samagotchi
89
86
  def build(recorder)
90
87
  Hooks::Runtime.new(
91
88
  notify: ->(text:, level:, hook:, fallback_for: nil) { recorder.notify(text, level, hook, fallback_for) },
92
- ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:, effects: nil|
93
- recorder.ask_user(question: question, options: options, header: header, allow_freeform: allow_freeform, hook: hook,
94
- effects: effects)
95
- },
89
+ ask_user: ->(question) { recorder.ask_user(question) },
96
90
  stop_turn: ->(reason:, hook:) { recorder.stop(:turn, reason, hook) },
97
91
  steer: ->(text:, hook:) { recorder.steer(text, hook) },
98
92
  stop_generation: ->(reason:, hook:) { recorder.stop(:generation, reason, hook) }
@@ -3,6 +3,7 @@
3
3
  require_relative "../plugin/card"
4
4
  require_relative "../plugin/context"
5
5
  require_relative "../guardrails/parent_policy"
6
+ require_relative "../hook_question"
6
7
 
7
8
  module Samagotchi
8
9
  module Testing
@@ -13,11 +14,6 @@ module Samagotchi
13
14
  Steer = Data.define(:text, :hook)
14
15
  # A stop_turn or stop_generation (+kind+ :turn or :generation).
15
16
  Stop = Data.define(:kind, :reason, :hook)
16
- # An ask_user question; effects: its options' tags as the bundle gave
17
- # them (label => effect), nil for none.
18
- Question = Data.define(:question, :options, :header, :allow_freeform, :hook, :effects) do
19
- def initialize(effects: nil, **) = super
20
- end
21
17
  # An ask_model request: the request Context built (SideQuestion), its
22
18
  # limits, cancel controller and the model: it named (nil: the session's;
23
19
  # the kit doesn't check it against any hosts), and +source+, the asking
@@ -107,11 +103,12 @@ module Samagotchi
107
103
  # Effects chi would refuse (a label the options don't have, an
108
104
  # unknown effect: Guardrails::ParentPolicy.option_tags) ask no one, as
109
105
  # in chi: nil, nothing recorded, no answer taken.
110
- def ask_user(question:, options:, header:, allow_freeform:, hook:, effects: nil)
111
- return nil if Guardrails::ParentPolicy.option_tags(options, effects) == false
106
+ # @param question [HookQuestion] recorded as asked (#questions; its
107
+ # effects as the bundle gave them: label => effect, nil for none)
108
+ def ask_user(question)
109
+ return nil if Guardrails::ParentPolicy.option_tags(question.options, question.effects) == false
112
110
 
113
- record(@questions, Question.new(question: question, options: options, header: header,
114
- allow_freeform: allow_freeform, hook: hook, effects: effects))
111
+ record(@questions, question)
115
112
  answer = @mutex.synchronize { @answers.shift }
116
113
  answer.respond_to?(:call) ? answer.call(@questions.last) : answer
117
114
  end
@@ -124,6 +124,32 @@ module Samagotchi
124
124
  File.join(memory_dir(scope, cwd: cwd || Dir.pwd), "#{name}.md")
125
125
  end
126
126
 
127
+ # The model notes chi loads into the prompt for +model_id+ (the bare
128
+ # id, no host prefix: `deepseek/deepseek-v4.1-flash`), from this
129
+ # sandbox's memories (the system scope's, then the project scope's for
130
+ # the checkout at +cwd+), matched as chi matches them: each `models:`
131
+ # glob against the id and its model key.
132
+ # @param small [Boolean, nil] whether the model counts as a small one
133
+ # for a `small` entry; nil: chi's own rule (guardrails.small_models)
134
+ # @return [Array<#name, #scope, #chars, #body>] the notes in prompt
135
+ # order (`.map(&:name)` for the names); none: []
136
+ def model_notes_for(model_id, small: nil, cwd: nil)
137
+ require_relative "../model_notes"
138
+ require_relative "../model_overlay"
139
+ small_check = small.nil? ? nil : -> { small }
140
+ in_dir(cwd) do
141
+ ModelNotes.for(name: model_id, key: ModelOverlay.key_for(model_id), small: small_check, warn: false)
142
+ end
143
+ end
144
+
145
+ # The sizes chi warns over (it still loads the notes): one note's
146
+ # body (+:note+) and all of a model's notes (+:total+), in chars.
147
+ # @return [Hash{Symbol => Integer}]
148
+ def model_note_limits
149
+ require_relative "../model_notes"
150
+ { note: ModelNotes::NOTE_LIMIT, total: ModelNotes::TOTAL_LIMIT }
151
+ end
152
+
127
153
  # @return [String] a temp folder for the session's cwd
128
154
  def work_dir = File.join(@root, "work")
129
155
 
@@ -234,11 +234,11 @@ module Samagotchi
234
234
  DelegateWait.mark_seen(parent.id, child, state_dir: state_dir)
235
235
  delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{ClientId::DELEGATE_PREFIX}#{parent.id[0, 8]}",
236
236
  state_dir: state_dir)
237
- case delivered[:status]
237
+ case delivered.status
238
238
  when :accepted
239
- DelegateWait.mark_turn(parent.id, id, follow_up_turn(id, delivered[:ack], state_dir: state_dir), state_dir: state_dir)
239
+ DelegateWait.mark_turn(parent.id, id, follow_up_turn(id, delivered.enqueued_id, state_dir: state_dir), state_dir: state_dir)
240
240
  id
241
- when :refused then "Error: session #{id[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
241
+ when :refused then "Error: session #{id[0, 8]} refused the message (#{delivered.error})"
242
242
  when :timeout then "Error: the worker of session #{id[0, 8]} did not answer in time, so the message was not sent"
243
243
  else "Error: the message to session #{id[0, 8]} could not be written"
244
244
  end
@@ -251,24 +251,24 @@ module Samagotchi
251
251
 
252
252
  # The child's turn a delivered follow-up joins, for the wait
253
253
  # (DelegateCursor#turn): the running turn's (its checkpoint mark), the
254
- # follow-up merging into it; else the ack's enqueued_id, the follow-up
255
- # running as a turn of its own. Only from a worker that names
256
- # turn_ids; nil otherwise, or when the running turn has no id (a
257
- # delegate's first turn): the wait takes the next reply.
254
+ # follow-up merging into it; else the delivery's +enqueued_id+, the
255
+ # follow-up running as a turn of its own. Only
256
+ # from a worker that names turn_ids; nil otherwise, or when the
257
+ # running turn has no id (a delegate's first turn): the wait takes the
258
+ # next reply.
258
259
  # A follow-up steered into a turn that ended before it merged runs as
259
260
  # its own turn, yet the wait is on the ended one (LOW, as before).
260
261
  # One the child's idle look missed being merged into a turn starting
261
262
  # then never runs as its own: ReplyWait falls back to that turn's
262
263
  # reply (merged_elsewhere?).
263
264
  # @return [String, nil]
264
- def self.follow_up_turn(id, ack, state_dir:)
265
+ def self.follow_up_turn(id, enqueued_id, state_dir:)
265
266
  return nil unless ReplyWait.turn_ids?(Session.session_dir(id, state_dir: state_dir))
266
267
 
267
268
  child = Session.load(id, state_dir: state_dir)
268
269
  return child.interrupted_turn&.enqueued_id if child.status == Session::STATUS_RUNNING
269
270
 
270
- enqueued_id = ack.is_a?(Hash) ? (ack["enqueued_id"] || ack[:enqueued_id]) : nil
271
- enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
271
+ enqueued_id
272
272
  rescue ArgumentError
273
273
  nil
274
274
  end
@@ -4,6 +4,7 @@ require "json"
4
4
  require "fileutils"
5
5
  require_relative "../atomic_file"
6
6
  require_relative "../session"
7
+ require_relative "../reply_wait"
7
8
 
8
9
  module Samagotchi
9
10
  module Tools
@@ -14,32 +15,54 @@ module Samagotchi
14
15
  # of the child's turn the last follow-up went to (Delegate.follow_up),
15
16
  # whose own reply a wait takes (ReplyWait's turn:); nil once that turn
16
17
  # was reported, or when unknown (a new child, an older worker).
17
- # +report_claim+: a report reserved until the parent's turn is settled.
18
+ # +report_claim+: a report reserved until the parent's turn is settled
19
+ # (ReportClaim).
18
20
  DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages, :turn, :report_claim) do
19
21
  def initialize(reply_file: nil, last_turn: nil, question_id: nil, messages: nil, turn: nil, report_claim: nil) = super
20
22
 
21
23
  def self.empty = new
22
24
 
23
- # @param baseline [Hash, nil] ReplyWait.baseline_of's
25
+ # The cursor with no report reserved: what it is once the reserving
26
+ # turn is settled, and how two cursors compare apart from a claim.
27
+ def without_claim = with(report_claim: nil)
28
+
29
+ # @param baseline [ReplyWait::Baseline, nil] ReplyWait.baseline_of's
24
30
  def with_baseline(baseline)
25
- b = baseline || {}
26
- with(messages: b[:messages], question_id: b[:question_id], last_turn: b[:last_turn])
31
+ with(messages: baseline&.messages, question_id: baseline&.question_id, last_turn: baseline&.last_turn)
27
32
  end
28
33
 
29
- # @return [Hash, nil] ReplyWait's baseline:, nil when none was taken
34
+ # @return [ReplyWait::Baseline, nil] ReplyWait's baseline:, nil when
35
+ # none was taken
30
36
  def baseline
31
37
  return nil if messages.nil?
32
38
 
33
- { messages: messages, question_id: question_id, last_turn: last_turn }
39
+ ReplyWait::Baseline.new(messages: messages, question_id: question_id, last_turn: last_turn)
34
40
  end
35
41
 
36
- def to_json_hash = to_h.transform_keys(&:to_s).compact
42
+ def to_json_hash = to_h.merge(report_claim: report_claim&.to_json_hash).transform_keys(&:to_s).compact
37
43
 
38
44
  def self.from_json_hash(data)
39
45
  return empty unless data.is_a?(Hash)
40
46
 
41
47
  new(reply_file: data["reply_file"], last_turn: data["last_turn"], question_id: data["question_id"],
42
- messages: data["messages"], turn: data["turn"], report_claim: data["report_claim"])
48
+ messages: data["messages"], turn: data["turn"], report_claim: ReportClaim.from_json_hash(data["report_claim"]))
49
+ end
50
+ end
51
+
52
+ # A delegate report reserved in the parent's cursor (ChildReports) until
53
+ # the parent's turn that took it is kept or rolled back: +id+ the
54
+ # claim's id (the taking turn's Report#claim_id), +file+ the reply file
55
+ # reported (nil for a question), +cursor+ the DelegateCursor the report
56
+ # moves the parent to once kept (its own claim empty). In delegates.json
57
+ # it is {"id", "file", "cursor"}, the file's key kept even when nil.
58
+ ReportClaim = Data.define(:id, :file, :cursor) do
59
+ def to_json_hash = { "id" => id, "file" => file, "cursor" => cursor.to_json_hash }
60
+
61
+ # @return [ReportClaim, nil] nil for no claim (or not a Hash)
62
+ def self.from_json_hash(data)
63
+ return nil unless data.is_a?(Hash)
64
+
65
+ new(id: data["id"], file: data["file"], cursor: DelegateCursor.from_json_hash(data["cursor"]))
43
66
  end
44
67
  end
45
68
 
@@ -77,6 +100,7 @@ module Samagotchi
77
100
  end
78
101
 
79
102
  # Reserve a report only if the cursor read to build it is still current.
103
+ # @param claim [ReportClaim]
80
104
  # @return [DelegateCursor, nil] the reserved cursor, or nil if another path moved it
81
105
  def claim_report(parent_id, child_id, expected:, claim:, state_dir:)
82
106
  locked(parent_id, state_dir: state_dir) do