samagotchi 0.30.0 → 0.31.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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -1
  3. data/docs/configuration.md +4 -0
  4. data/docs/internals/engine.md +1 -1
  5. data/docs/sessions.md +9 -2
  6. data/lib/samagotchi/bridge/card_store.rb +1 -1
  7. data/lib/samagotchi/bridge/turn_notice.rb +1 -1
  8. data/lib/samagotchi/bundles/system/delegated.md +1 -0
  9. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  10. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  11. data/lib/samagotchi/child_reports.rb +214 -0
  12. data/lib/samagotchi/client.rb +35 -4
  13. data/lib/samagotchi/config.rb +5 -0
  14. data/lib/samagotchi/engine.rb +2 -0
  15. data/lib/samagotchi/idle_recap.rb +4 -0
  16. data/lib/samagotchi/kernel_loop.rb +44 -5
  17. data/lib/samagotchi/llm/chat_loop.rb +2 -1
  18. data/lib/samagotchi/llm/errors.rb +9 -0
  19. data/lib/samagotchi/log_subscriber.rb +1 -1
  20. data/lib/samagotchi/prompt_warmup.rb +30 -5
  21. data/lib/samagotchi/reply_wait.rb +39 -8
  22. data/lib/samagotchi/session.rb +26 -5
  23. data/lib/samagotchi/session_inbox.rb +34 -1
  24. data/lib/samagotchi/session_manager.rb +32 -3
  25. data/lib/samagotchi/steer.rb +39 -7
  26. data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
  27. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  28. data/lib/samagotchi/terminal_ui/formatting.rb +31 -4
  29. data/lib/samagotchi/terminal_ui.rb +9 -0
  30. data/lib/samagotchi/tool_call_parser.rb +40 -0
  31. data/lib/samagotchi/tool_declarations.rb +7 -7
  32. data/lib/samagotchi/tools/delegate.rb +13 -4
  33. data/lib/samagotchi/tools/delegate_cursor.rb +91 -0
  34. data/lib/samagotchi/tools/delegate_wait.rb +50 -31
  35. data/lib/samagotchi/tools/task_runtime.rb +21 -2
  36. data/lib/samagotchi/tools/task_wait.rb +2 -2
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/app.rb +8 -2
  39. data/lib/samagotchi/web/public/app.js +12 -4
  40. data/lib/samagotchi/web/public/format.js +8 -1
  41. data/lib/samagotchi/web/public/notify.js +4 -2
  42. data/lib/samagotchi/web/public/turn_events.js +26 -8
  43. data/lib/samagotchi/web/public/turn_view.js +23 -4
  44. data/lib/samagotchi/worker.rb +217 -8
  45. metadata +3 -1
@@ -93,7 +93,13 @@ export function isOwn(clientId, myId) {
93
93
 
94
94
  // As the TUI labels them (Formatting::CLIENT_LABELS); this file's label
95
95
  // helpers and the TUI's agree per spec/shared/labels_matrix.json.
96
- const CLIENT_LABELS = Object.freeze({ tui: "tui", web: "web", system: "reminder", delegate: "delegate" });
96
+ const CLIENT_LABELS = Object.freeze({ tui: "tui", web: "web", system: "reminder", delegate: "delegate", child: "delegate report" });
97
+
98
+ // A delegate child's report comes in as client child:<id8> (ChildReports).
99
+ const REPORT_CLIENT = "child:";
100
+ export function isReportClient(clientId) {
101
+ return String(clientId || "").startsWith(REPORT_CLIENT);
102
+ }
97
103
 
98
104
  // The label on another client's prompt, by its client_id prefix.
99
105
  export function clientLabel(clientId) {
@@ -119,9 +125,10 @@ export function hookNoticeLabel(hook) {
119
125
  }
120
126
 
121
127
  // The row a step gets when the loop asks again after an empty answer, or
122
- // after a plugin cut the generation (stopped_by: the bundle).
123
- export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy } = {}) {
124
- const what = stoppedBy ? `cut by ${stoppedBy}` : "empty answer";
128
+ // after a plugin cut the generation (stopped_by: the bundle), or after a
129
+ // corrupt native generation (malformed).
130
+ export function emptyRetryLine({ attempt, of, stopped_by: stoppedBy, malformed } = {}) {
131
+ const what = stoppedBy ? `cut by ${stoppedBy}` : malformed ? "malformed answer" : "empty answer";
125
132
  return `↻ ${what}, asking again (${attempt}/${of})`;
126
133
  }
127
134
 
@@ -381,9 +388,14 @@ export function composerAfterAck({ sent = "", current = "", refill = "" } = {})
381
388
  // tag — this tab's own local echo gets its enqueued_id
382
389
  // start — the bubble's turn started (drops the "queued" badge)
383
390
  // steer — the bubble was merged into the running turn
391
+ // report — the merge brought a delegate child's report (origin
392
+ // child:<id8>, no bubble anywhere): its text gets a labelled one
384
393
  // `known(enqueuedId)` says whether a bubble carries that id already;
385
- // `unmatchedMerge` whether the last input_merged had an origin with no bubble.
386
- export function promptOps(event, { myId, known, unmatchedMerge = false }) {
394
+ // `unmatchedMerge` whether the last input_merged had an origin with no
395
+ // bubble ("report": a delegate report's); `wakeStart` whether this is the
396
+ // first merge of a turn chi ran for reports (its bubble starts the turn,
397
+ // no "steered" badge, as a reload shows it).
398
+ export function promptOps(event, { myId, known, unmatchedMerge = false, wakeStart = false }) {
387
399
  if (!event) return [];
388
400
  switch (event.type) {
389
401
  case "turn_enqueued": {
@@ -403,12 +415,18 @@ export function promptOps(event, { myId, known, unmatchedMerge = false }) {
403
415
  }
404
416
  return [withImages({ op: "add", enqueuedId: id, prompt: event.prompt, state: "started", label: clientLabel(origin.client_id) }, event.images)];
405
417
  }
406
- case "input_merged":
407
- return (event.origins || []).filter((o) => o && o.enqueued_id).map((o) =>
418
+ case "input_merged": {
419
+ const ops = (event.origins || []).filter((o) => o && o.enqueued_id).map((o) =>
408
420
  known(o.enqueued_id) ? { op: "steer", enqueuedId: o.enqueued_id } : { op: "steer", enqueuedId: o.enqueued_id, unmatched: true },
409
421
  );
422
+ if ((event.origins || []).some((o) => o && !o.enqueued_id && isReportClient(o.client_id))) ops.push({ op: "report" });
423
+ return ops;
424
+ }
410
425
  case "pending_input_merged":
411
426
  if (!unmatchedMerge || !event.content) return [];
427
+ if (unmatchedMerge === "report") {
428
+ return [{ op: "add", enqueuedId: null, prompt: event.content, state: wakeStart ? null : "steered", label: clientLabel(REPORT_CLIENT) }];
429
+ }
412
430
  return [{ op: "add", enqueuedId: null, prompt: event.content, state: "steered", label: null }];
413
431
  default:
414
432
  return [];
@@ -14,7 +14,7 @@ import { leavesBlock } from "./card.js";
14
14
  import { copySourceAttr } from "./copy.js";
15
15
  import { commandBlockHtml, rowHover } from "./command_view.js";
16
16
  import { activityDiffHtml } from "./diff_view.js";
17
- import { escapeHtml, messageBodyHtml, noteHtml, normalize, steerRowHtml } from "./format.js";
17
+ import { escapeHtml, messageBodyHtml, noteHtml, normalize, sourceLabel, steerRowHtml } from "./format.js";
18
18
  import { thumbsHtml } from "./images.js";
19
19
  import { shouldFollowScroll } from "./scroll.js";
20
20
  import { emptyAnswerLine, hookNoticeLabel } from "./turn_events.js";
@@ -805,13 +805,28 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
805
805
  const { body, renderedMarkdown } = messageBodyHtml(m);
806
806
  return `<div class="bubble output${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(m, renderedMarkdown)}>${body}</div>${timing}`;
807
807
  };
808
+ // The live bubble's origin label (app.js makeUserBubble), for a line
809
+ // chi brought in: a delegate report.
810
+ const originLabelHtml = (m) => {
811
+ const label = sourceLabel(m?.source);
812
+ return label ? `<span class="origin-label">${escapeHtml(label)}</span>` : "";
813
+ };
814
+ // The first text after a delegate report merged mid-turn, unless it is
815
+ // the turn's answer (shown anyway).
816
+ const reportReplyHtml = (i, group) => {
817
+ if (items[i]?.source !== "delegate_report") return "";
818
+ const j = items.findIndex((m, k) => k > i && m?.role === "assistant" && String(m.content ?? "").trim());
819
+ if (j < 0 || j === group.answer || items.slice(i + 1, j).some((m) => m?.role === "user" && !m.merged)) return "";
820
+ const { body, renderedMarkdown } = messageBodyHtml(items[j]);
821
+ return `<div class="bubble output report-reply${renderedMarkdown ? " markdown" : ""}"${copySourceAttr(items[j], renderedMarkdown)}>${body}</div>`;
822
+ };
808
823
  const steerHtml = (i, bubble) => steerRowHtml({ source: items[i].source, text: items[i].content }, { bubble });
809
824
  return turnGroups(items, timing).map((group) => {
810
825
  if (group.kind === "note") return `<div class="bubble note">${noteHtml(items[group.i])}</div>`;
811
826
  if (group.kind === "steer") return steerHtml(group.i, true);
812
827
  if (group.kind === "answer") return answerHtml(items[group.answer], "");
813
828
  const prompt = items[group.user];
814
- const parts = [`<div class="bubble user"${copySourceAttr(prompt, false)}><div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
829
+ const parts = [`<div class="bubble user"${copySourceAttr(prompt, false)}>${originLabelHtml(prompt)}<div class="user-message">${messageBodyHtml(prompt).body}${thumbs(prompt.images)}</div></div>`];
815
830
  const [only] = group.steps;
816
831
  const ended = group.answer !== null || group.emptyAnswer != null;
817
832
  const textless = only && (only.i === null || !String(items[only.i].content ?? ""));
@@ -843,9 +858,13 @@ export function turnHistoryHtml(items, timing, { thumbs }) {
843
858
  const loose = noBlock ? [...group.steps.flatMap((step) => step.steers || []), ...(group.steers || [])] : group.steers || [];
844
859
  parts.push(loose.map((i) => steerHtml(i, true)).join(""));
845
860
  // The user's lines merged into the turn: the live "steered" bubbles,
846
- // after its steps and before its answer.
861
+ // after its steps and before its answer. A delegate report's is
862
+ // followed by the text that answered it when that isn't the answer: a
863
+ // small model tells its user about a report before its next tool call,
864
+ // and that step's text would stay folded in the block.
847
865
  parts.push((group.merged || []).map((i) =>
848
- `<div class="bubble user steered" data-user-state="steered"${items[i].step ? ` data-step="${Number(items[i].step)}"` : ""}${copySourceAttr(items[i], false)}><div class="user-message">${messageBodyHtml(items[i]).body}${thumbs(items[i].images)}</div><span class="state-badge">steered</span></div>`).join(""));
866
+ `<div class="bubble user steered" data-user-state="steered"${items[i].step ? ` data-step="${Number(items[i].step)}"` : ""}${copySourceAttr(items[i], false)}>${originLabelHtml(items[i])}<div class="user-message">${messageBodyHtml(items[i]).body}${thumbs(items[i].images)}</div><span class="state-badge">steered</span></div>` +
867
+ reportReplyHtml(i, group)).join(""));
849
868
  // A turn that ended with no answer: chi's muted notice where the answer
850
869
  // would be, before the timing, as live.
851
870
  if (group.emptyAnswer != null) parts.push(`${emptyAnswerHtml(items[group.emptyAnswer])}${timingHtml(group)}`);
@@ -17,6 +17,7 @@ require_relative "continue_offer"
17
17
  require_relative "iteration_limit"
18
18
  require_relative "session_commands"
19
19
  require_relative "model_profile"
20
+ require_relative "child_reports"
20
21
  require_relative "tools/task_runtime"
21
22
 
22
23
  module Samagotchi
@@ -45,6 +46,11 @@ module Samagotchi
45
46
  # turn) is refused as busy.
46
47
  class Worker
47
48
  FALLBACK_TICK_SECONDS = 5
49
+ # A worker just started runs no wake turn for delegate reports this
50
+ # long: a worker spawned for a message (chi send to a stopped parent)
51
+ # gets it over the Bridge a moment after it starts, and that message's
52
+ # turn goes first (the reports join it).
53
+ WAKE_START_GRACE = 2.0
48
54
  # How much of a command's output goes into its :command_ran.
49
55
  COMMAND_OUTPUT_LIMIT = 4096
50
56
  BUSY_OUTPUT = "busy: wait for the turn to end"
@@ -89,6 +95,25 @@ module Samagotchi
89
95
  @discard = false
90
96
  # The exit asked for is a restart (POST /exit restart: true).
91
97
  @exit_restart = false
98
+ # The lines the running turn's drain merged ([prompt, origin]), for a
99
+ # failed prompt turn to hand back; reset as each turn begins.
100
+ @merged_this_turn = []
101
+ # A delegate child's side (ChildRing): whether the running turn is
102
+ # its parent's (it rings when it ends), whether that turn rang a
103
+ # question, and whether the next turn rings whatever its origin (it
104
+ # follows a question the parent was rung about: D10).
105
+ @reporting_turn = false
106
+ @asked_parent = false
107
+ @owes_parent = false
108
+ @initial_turn = false
109
+ # A parent's side: turns run for delegate reports since the last human
110
+ # input (session.max_wakes), whether a failed one paused them until
111
+ # then, whether the budget notice went out, and the reports a wake
112
+ # turn's first boundary hands over.
113
+ @wakes_in_a_row = 0
114
+ @wakes_paused = false
115
+ @budget_noticed = false
116
+ @wake_reports = nil
92
117
  end
93
118
 
94
119
  # Whether the session was empty as the worker left it, so the caller
@@ -96,6 +121,12 @@ module Samagotchi
96
121
  # checks again then).
97
122
  def discard? = @discard
98
123
 
124
+ # Whether delegate reports wait that an idle parent runs a turn for
125
+ # (#wake_state): SessionManager.run_session_loop starts a new worker
126
+ # for them after this one leaves. Reports the budget or a failure held
127
+ # back wait for the next human input instead.
128
+ def wake_due? = wake_state == :due
129
+
99
130
  # What a new session starts on (and /model resets to); nil before #run.
100
131
  attr_reader :default_model
101
132
 
@@ -105,6 +136,7 @@ module Samagotchi
105
136
  # (chi stop), :crashed when the loop raised (the session is marked
106
137
  # errored); SessionManager.run_session_loop turns it into the exit
107
138
  def run
139
+ @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
108
140
  @session = Session.load(@session_id, state_dir: @state_dir)
109
141
  drop_dead_question
110
142
  @engine = build_engine
@@ -114,7 +146,7 @@ module Samagotchi
114
146
  @engine.session = @session
115
147
  # Input already waiting in the inbox starts the next turn at once: no
116
148
  # turn-end warm-up then.
117
- @engine.next_turn_waiting = -> { SessionInbox.find_new_input_files(@session_dir).any? }
149
+ @engine.next_turn_waiting = -> { SessionInbox.find_new_input_files(@session_dir).any? || wake_due? }
118
150
  @turn_flow = TurnFlow.new(engine: @engine)
119
151
  # Its question's answer is queued as the matching /continue (the
120
152
  # Bridge exists by the time anyone answers).
@@ -131,7 +163,11 @@ module Samagotchi
131
163
  @turn_end_seq = 0
132
164
  @engine.subscribe(observer: lambda { |event|
133
165
  @turn_end_seq = event[:event_seq] if Events::TURN_END.include?(event[:type])
166
+ @turn_id = event[:turn_id] if event[:type] == :turn_started
167
+ ring_question(event) if event[:type] == :question_requested
134
168
  })
169
+ # This session's own delegate children's news (rings in children/).
170
+ @child_reports = ChildReports.new(session_id: @session_id, state_dir: @state_dir)
135
171
  # /model's default is the config's, as in the REPL; the Engine started
136
172
  # on the session's model.
137
173
  @default_model = ModelProfile.required_model_name(nil)
@@ -190,17 +226,18 @@ module Samagotchi
190
226
  absorb_notes
191
227
 
192
228
  if (prompt = take_initial_prompt)
193
- run_prompt(prompt, nil) unless initial_command(prompt)
229
+ run_initial_prompt(prompt) unless initial_command(prompt)
194
230
  next
195
231
  end
196
232
 
197
233
  input_files = SessionInbox.find_new_input_files(@session_dir)
198
234
  if input_files.empty?
235
+ next if run_wake_turn
199
236
  next if run_due_reminders
200
237
  return left(:idle_exit) if @idle_exit.due? && leave_idle
201
238
  return left(@exit_restart ? :restart : :exit_requested) if @exit_requested && leave_on_request
202
239
 
203
- @waker.wait(@poll_interval)
240
+ @waker.wait(idle_wait)
204
241
  next
205
242
  end
206
243
 
@@ -216,6 +253,8 @@ module Samagotchi
216
253
  # Its stderr is /dev/null: the log is the only trace of why.
217
254
  Log.exception(:worker, "crashed", e)
218
255
  Session.mark_error(@session_id, reason: e.message, state_dir: @state_dir)
256
+ # A delegate's parent hears of it (best effort).
257
+ ChildRing.ring(@session, why: "crash", state_dir: @state_dir) if @session
219
258
  :crashed
220
259
  ensure
221
260
  # The anytime commands finish and the plugins' services stop (a
@@ -333,6 +372,15 @@ module Samagotchi
333
372
  claimed.each { |file| FileUtils.rm_f(file) }
334
373
  end
335
374
 
375
+ # The first prompt (spawn_session's): a delegate child's task, which is
376
+ # its parent's although no client sent it.
377
+ def run_initial_prompt(prompt)
378
+ @initial_turn = true
379
+ run_prompt(prompt, nil)
380
+ ensure
381
+ @initial_turn = false
382
+ end
383
+
336
384
  def run_input_file(input_file)
337
385
  claimed_file = SessionInbox.claim_input_file(input_file)
338
386
  return unless claimed_file
@@ -355,7 +403,6 @@ module Samagotchi
355
403
  user_input(origin&.dig(:client_id))
356
404
  @continue_offer.drop(origin)
357
405
  @turn_flow.before_prompt_turn
358
- @merged_this_turn = []
359
406
  run_engine_turn(prompt, origin: origin, max_iterations: max_iterations(no_interrupt),
360
407
  images: images) do |result, error|
361
408
  if error
@@ -387,6 +434,85 @@ module Samagotchi
387
434
  true
388
435
  end
389
436
 
437
+ # :due when an idle parent should run a turn for its delegate
438
+ # children's reports now (session.delegate_reports: wake): rings wait,
439
+ # no continue offer does (they merge into its turn), wakes aren't paused
440
+ # by a failed one, and the wake budget isn't spent; :budget when only
441
+ # the budget stops it; nil otherwise.
442
+ def wake_state
443
+ return nil unless @child_reports && ChildRing.mode == "wake"
444
+ return nil if @wakes_paused || @turn_flow&.awaiting_continue?
445
+ return nil unless @child_reports.waiting?
446
+
447
+ @wakes_in_a_row < max_wakes ? :due : :budget
448
+ end
449
+
450
+ # Run a turn nobody typed for the reports the rings bring (a wake turn):
451
+ # a continue turn (no prompt bubble) whose first boundary merges them,
452
+ # with origin child:<id8>. A failure goes back to the checkpoint, keeps
453
+ # the rings and pauses wakes until a human's input (a provider outage
454
+ # doesn't loop).
455
+ # @return [Boolean] whether a wake turn ran
456
+ def run_wake_turn
457
+ return false if wake_grace_left.positive?
458
+
459
+ state = wake_state
460
+ budget_notice if state == :budget
461
+ return false unless state == :due
462
+
463
+ reports, = take_child_reports
464
+ # The turn must not generate with nothing to say: the drain gets this
465
+ # exact list at its first boundary.
466
+ return false if reports.empty?
467
+
468
+ @wake_reports = reports
469
+ @wakes_in_a_row += 1
470
+ Log.info(:worker, "delegate_wake_turn", reports: reports.size, in_a_row: @wakes_in_a_row)
471
+ @turn_flow.before_prompt_turn
472
+ run_engine_turn(nil, continue: true, origin: reports.first.origin, max_iterations: IterationLimit.for) do |result, error|
473
+ if error
474
+ note = TurnNote.failed(error.respond_to?(:summary) ? error.summary : error.message)
475
+ @turn_flow.prompt_turn_failed(note: note)
476
+ @wakes_paused = true
477
+ else
478
+ @continue_offer.after_turn(result)
479
+ end
480
+ end
481
+ true
482
+ ensure
483
+ @wake_reports = nil
484
+ end
485
+
486
+ def wake_grace_left
487
+ WAKE_START_GRACE - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at)
488
+ end
489
+
490
+ # The idle loop's sleep: the fallback tick, or less while reports wait
491
+ # out the start grace.
492
+ def idle_wait
493
+ left = wake_grace_left
494
+ left.positive? && wake_state == :due ? [left, @poll_interval].min : @poll_interval
495
+ end
496
+
497
+ def max_wakes
498
+ value = Integer(Config.get(ChildRing::MAX_WAKES_KEY), exception: false)
499
+ value&.positive? ? value : ChildRing::MAX_WAKES_DEFAULT
500
+ rescue StandardError
501
+ ChildRing::MAX_WAKES_DEFAULT
502
+ end
503
+
504
+ # The wake budget is spent: the parent's UIs hear once that reports
505
+ # wait for the next message.
506
+ def budget_notice
507
+ return if @budget_noticed
508
+
509
+ @budget_noticed = true
510
+ count = SessionInbox.find_ring_files(@session_dir).map { |f| SessionInbox.read_ring(f)&.dig(:child_id) }.uniq.size
511
+ @engine.announce(type: :hook_notice, hook: "delegate", level: :info, between_turns: true,
512
+ text: "#{count} delegate report#{"s" if count != 1} waiting; #{count == 1 ? "it joins" : "they join"} " \
513
+ "your next message (#{max_wakes} turn#{"s" if max_wakes != 1} ran for reports in a row, #{ChildRing::MAX_WAKES_KEY})")
514
+ end
515
+
390
516
  def max_iterations(no_interrupt) = IterationLimit.for(no_interrupt: no_interrupt)
391
517
 
392
518
  # @return [Boolean] whether any command ran
@@ -496,6 +622,10 @@ module Samagotchi
496
622
  # @yieldparam result [Object, nil] Engine#run_turn's, nil on a failure
497
623
  # @yieldparam error [StandardError, nil]
498
624
  def run_engine_turn(prompt, **turn_args)
625
+ # Every kind of turn merges steering (a reminder turn too, which may be
626
+ # a fresh worker's first), so the list is this turn's from the start.
627
+ @merged_this_turn = []
628
+ @reporting_turn = reports_to_parent?(turn_args[:origin])
499
629
  begin
500
630
  @session.status = Session::STATUS_RUNNING
501
631
  @session.save(state_dir: @state_dir)
@@ -519,6 +649,51 @@ module Samagotchi
519
649
  SessionInbox.write_output(@session_dir, response)
520
650
  end
521
651
  save_or_log(:turn) { save_session }
652
+ settle_child_reports(error)
653
+ ring_parent_after_turn
654
+ end
655
+
656
+ # The delegate reports this turn took: a kept turn moves the cursors on
657
+ # and deletes their rings; a failed one (rolled back) leaves the rings
658
+ # for the next turn.
659
+ def settle_child_reports(error)
660
+ error ? @child_reports.release : @child_reports.commit
661
+ end
662
+
663
+ # A delegate child's turn that was its parent's (#reports_to_parent?)
664
+ # rings the parent, after the save: the parent reads a settled child.
665
+ # A question it rang about that still waits makes the next turn ring
666
+ # too, whoever starts it (the user answering Continue on the web).
667
+ def ring_parent_after_turn
668
+ ChildRing.ring(@session, why: "turn_end", state_dir: @state_dir) if @reporting_turn
669
+ @owes_parent = @asked_parent && !@session.pending_question.nil?
670
+ @asked_parent = false
671
+ @reporting_turn = false
672
+ end
673
+
674
+ # Whether the turn starting now is a delegate child's parent's: its
675
+ # task (the initial prompt), a follow-up, a continue the parent
676
+ # answered, a reminder, anything but a human typing into the child
677
+ # (ArchiveStore.user_input?), and any turn after a question the parent
678
+ # was rung about.
679
+ def reports_to_parent?(origin)
680
+ return false unless @session.delegate?
681
+
682
+ @initial_turn || @owes_parent || !ArchiveStore.user_input?(origin&.dig(:client_id))
683
+ end
684
+
685
+ # The Engine published a question (after the question desk saved it).
686
+ # In a turn that reports to the parent, the model's own question and
687
+ # the step-limit continue ring it; approvals and hooks' questions are
688
+ # the user's, on this session's card.
689
+ def ring_question(event)
690
+ return unless @reporting_turn
691
+
692
+ kind = event.dig(:pending_question, :kind).to_s
693
+ return unless ["", ContinueOffer::KIND].include?(kind)
694
+
695
+ ChildRing.ring(@session, why: "question", state_dir: @state_dir)
696
+ @asked_parent = true
522
697
  end
523
698
 
524
699
  # A save between turns (the turn's own, a command's, the notes', the
@@ -588,14 +763,42 @@ module Samagotchi
588
763
  end
589
764
  end
590
765
  merged.each { |_prompt, origin| user_input(origin&.dig(:client_id)) }
591
- unless merged.empty?
592
- @engine.announce(type: :input_merged, count: merged.size, origins: merged.filter_map(&:last))
766
+ # Delegate children's reports: never handed back on a failure
767
+ # (their rings stay for the next turn), so not in @merged_this_turn.
768
+ reports, mark = take_child_reports
769
+ unless merged.empty? && reports.empty?
770
+ @engine.announce(type: :input_merged, count: merged.size + reports.size,
771
+ origins: merged.filter_map(&:last) + reports.map(&:origin))
593
772
  @merged_this_turn.concat(merged)
594
773
  end
595
774
  # Each line keeps its sender (Steer.source_for_client): a chi send or
596
775
  # a delegating parent's line is saved and shown to the model as theirs.
597
- merged.map { |prompt, origin| Steer::Line.new(text: prompt, source: Steer.source_for_client(origin&.dig(:client_id))) }
776
+ lines = merged.map { |prompt, origin| Steer::Line.new(text: prompt, source: Steer.source_for_client(origin&.dig(:client_id))) } +
777
+ reports.map(&:line)
778
+ # A wake turn starts with its first line, whoever sent it.
779
+ lines[0] = lines[0].with(mark: mark) if mark && lines.any?
780
+ lines
781
+ end
782
+ end
783
+
784
+ # The reports for rings that came in since the turn last looked. A
785
+ # failure to read them must not take the turn's steering with it.
786
+ # A wake turn's first call gets the reports it was started for (their
787
+ # message is the turn's start: turn_start, turn_id, so a reload shows
788
+ # the turn as its own).
789
+ # @return [Array(Array<ChildReports::Report>, Hash|nil)] the reports
790
+ # and the mark of their message
791
+ def take_child_reports
792
+ if @wake_reports
793
+ reports = @wake_reports
794
+ @wake_reports = nil
795
+ return [reports, { turn_start: true, turn_id: @turn_id }.compact]
598
796
  end
797
+
798
+ [@child_reports.take, nil]
799
+ rescue StandardError => e
800
+ Log.warn(:worker, "delegate_reports_failed", error: e.class.name, msg: e.message)
801
+ [[], nil]
599
802
  end
600
803
 
601
804
  # Check again with the event log held, which the Bridge holds while it
@@ -696,8 +899,14 @@ module Samagotchi
696
899
 
697
900
  # A human's input (ArchiveStore.user_input?) un-archives the session;
698
901
  # a delegate's, a plugin's or a reminder's doesn't.
902
+ # It also resets the delegate-report wakes (budget and pause).
699
903
  def user_input(client_id)
700
- ArchiveStore.user_input(@session_id, state_dir: @state_dir) if ArchiveStore.user_input?(client_id)
904
+ return unless ArchiveStore.user_input?(client_id)
905
+
906
+ ArchiveStore.user_input(@session_id, state_dir: @state_dir)
907
+ @wakes_in_a_row = 0
908
+ @wakes_paused = false
909
+ @budget_noticed = false
701
910
  end
702
911
 
703
912
  def stopped_on_disk?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.0
4
+ version: 0.31.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -193,6 +193,7 @@ files:
193
193
  - lib/samagotchi/bundles/system/memory_guide.md
194
194
  - lib/samagotchi/bundles/system/self_map.md
195
195
  - lib/samagotchi/cancellation_controller.rb
196
+ - lib/samagotchi/child_reports.rb
196
197
  - lib/samagotchi/cli/command.rb
197
198
  - lib/samagotchi/cli/exit.rb
198
199
  - lib/samagotchi/cli/flags.rb
@@ -394,6 +395,7 @@ files:
394
395
  - lib/samagotchi/tools/builtins.rb
395
396
  - lib/samagotchi/tools/cancel_reminder.rb
396
397
  - lib/samagotchi/tools/delegate.rb
398
+ - lib/samagotchi/tools/delegate_cursor.rb
397
399
  - lib/samagotchi/tools/delegate_relay.rb
398
400
  - lib/samagotchi/tools/delegate_result.rb
399
401
  - lib/samagotchi/tools/delegate_wait.rb