samagotchi 0.63.0 → 0.65.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -1
  3. data/docs/architecture.md +32 -5
  4. data/docs/cli.md +41 -9
  5. data/docs/configuration.md +143 -11
  6. data/docs/internals/engine.md +25 -6
  7. data/docs/plugins.md +7 -0
  8. data/docs/sessions.md +14 -10
  9. data/docs/sub-agent.md +21 -4
  10. data/docs/testing.md +6 -0
  11. data/lib/samagotchi/answer_command.rb +5 -1
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +29 -5
  14. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/slow-commands/plugin.rb +5 -3
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/child_reports.rb +7 -2
  19. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  20. data/lib/samagotchi/client.rb +4 -2
  21. data/lib/samagotchi/composer_route.rb +90 -0
  22. data/lib/samagotchi/config.rb +11 -7
  23. data/lib/samagotchi/continue_offer.rb +17 -3
  24. data/lib/samagotchi/delivery.rb +13 -8
  25. data/lib/samagotchi/engine.rb +41 -7
  26. data/lib/samagotchi/generation_phase.rb +17 -0
  27. data/lib/samagotchi/host_config.rb +33 -12
  28. data/lib/samagotchi/host_model.rb +2 -2
  29. data/lib/samagotchi/host_registry.rb +34 -11
  30. data/lib/samagotchi/idle_client.rb +62 -25
  31. data/lib/samagotchi/idle_recap.rb +5 -2
  32. data/lib/samagotchi/idle_target.rb +30 -7
  33. data/lib/samagotchi/interrupted_turn.rb +7 -3
  34. data/lib/samagotchi/last_turn.rb +5 -2
  35. data/lib/samagotchi/llm/adapters.rb +41 -0
  36. data/lib/samagotchi/llm/anthropic_assembly.rb +210 -0
  37. data/lib/samagotchi/llm/anthropic_messages.rb +416 -0
  38. data/lib/samagotchi/llm/anthropic_wire.rb +238 -0
  39. data/lib/samagotchi/llm/api_key.rb +26 -9
  40. data/lib/samagotchi/llm/chat_loop.rb +113 -14
  41. data/lib/samagotchi/llm/dialect.rb +57 -0
  42. data/lib/samagotchi/llm/errors.rb +81 -12
  43. data/lib/samagotchi/llm/model_listing.rb +111 -0
  44. data/lib/samagotchi/llm/model_result.rb +9 -2
  45. data/lib/samagotchi/llm/openai_chat.rb +46 -93
  46. data/lib/samagotchi/model_profile.rb +1 -1
  47. data/lib/samagotchi/pending_input_queue.rb +10 -3
  48. data/lib/samagotchi/plugin/sessions.rb +27 -5
  49. data/lib/samagotchi/reasoning_history.rb +14 -4
  50. data/lib/samagotchi/recap_setup.rb +2 -1
  51. data/lib/samagotchi/reply_wait.rb +171 -6
  52. data/lib/samagotchi/self_report.rb +18 -28
  53. data/lib/samagotchi/send_command.rb +63 -19
  54. data/lib/samagotchi/session.rb +3 -2
  55. data/lib/samagotchi/session_commands.rb +1 -1
  56. data/lib/samagotchi/session_inbox.rb +63 -7
  57. data/lib/samagotchi/session_manager.rb +49 -20
  58. data/lib/samagotchi/session_metrics.rb +6 -11
  59. data/lib/samagotchi/steer_cut.rb +4 -0
  60. data/lib/samagotchi/terminal_ui/attached_loop.rb +55 -28
  61. data/lib/samagotchi/terminal_ui/event_renderer.rb +2 -1
  62. data/lib/samagotchi/terminal_ui/formatting.rb +7 -22
  63. data/lib/samagotchi/terminal_ui/status_row.rb +24 -3
  64. data/lib/samagotchi/terminal_ui.rb +72 -57
  65. data/lib/samagotchi/tool_activity.rb +5 -5
  66. data/lib/samagotchi/tools/delegate.rb +28 -1
  67. data/lib/samagotchi/tools/delegate_cursor.rb +9 -4
  68. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  69. data/lib/samagotchi/tools/execute.rb +11 -4
  70. data/lib/samagotchi/turn_checkpoint.rb +9 -3
  71. data/lib/samagotchi/turn_note.rb +22 -5
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/web/app.rb +2 -1
  74. data/lib/samagotchi/web/public/activity.js +3 -0
  75. data/lib/samagotchi/web/public/app.js +96 -13
  76. data/lib/samagotchi/web/public/commands.js +8 -8
  77. data/lib/samagotchi/web/public/composer.js +3 -3
  78. data/lib/samagotchi/web/public/delivery_line.js +228 -0
  79. data/lib/samagotchi/web/public/index.html +29 -0
  80. data/lib/samagotchi/web/public/live_turn.js +8 -0
  81. data/lib/samagotchi/web/public/stage_model.js +27 -7
  82. data/lib/samagotchi/web/public/stage_view.js +32 -10
  83. data/lib/samagotchi/web/public/stream_handlers.js +8 -2
  84. data/lib/samagotchi/web/public/timing.js +2 -1
  85. data/lib/samagotchi/web/public/turn_events.js +22 -8
  86. data/lib/samagotchi/web/public/turn_html.js +20 -4
  87. data/lib/samagotchi/web/public/turn_model.js +29 -12
  88. data/lib/samagotchi/web/public/turn_view.js +6 -4
  89. data/lib/samagotchi/worker.rb +7 -5
  90. metadata +9 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../client_id"
4
+ require_relative "../composer_route"
4
5
  require_relative "event_renderer"
5
6
  require_relative "formatting"
6
7
  require_relative "../prompt_history"
@@ -259,13 +260,17 @@ module Samagotchi
259
260
  take_session_state(event[:session_state_snapshot] || {})
260
261
  return render_snapshot(event[:snapshot] || {}, reset: event[:type] == :reset)
261
262
  when :turn_enqueued then show_enqueued(event)
262
- when :turn_started then start_turn(event)
263
+ when :turn_started
264
+ @status.take_event(event)
265
+ start_turn(event)
263
266
  when :turn_completed
267
+ @status.take_event(event)
264
268
  complete_turn(event)
265
269
  refresh_children
266
270
  own_turn_ended(event)
267
271
  # The renderer says how it ended (the REPL's words too).
268
272
  when :turn_canceled, :turn_failed
273
+ @status.take_event(event)
269
274
  @renderer.call(event)
270
275
  end_turn
271
276
  refresh_children
@@ -387,61 +392,72 @@ module Samagotchi
387
392
  end
388
393
 
389
394
  # /cut TEXT and /queue TEXT are this UI's own deliveries, read before
390
- # every command check (SessionCommands registers them for Tab and
391
- # /help only). A plain line sends no delivery: it goes in at the
392
- # running turn's next step, and never cuts.
393
- if (choice = steer_choice(text))
394
- return submit_delivery(*choice)
395
- end
395
+ # every command check (ComposerRoute; SessionCommands registers them
396
+ # for Tab and /help only). A plain line sends no delivery: it goes in
397
+ # at the running turn's next step, and never cuts.
398
+ route = ComposerRoute.call(text, running: @running, registry: command_registry)
399
+ return submit_delivery(route) if route.choice
396
400
 
397
401
  # The terminal's own commands (SessionCommands registers them, the
398
402
  # REPL reads the same words). Before the continue offer: the
399
403
  # leaving ones are no answer to it.
400
- local = command_registry.lookup_local(text)&.id
404
+ local = route.local
401
405
  return submit(nil) if local == :detach
402
406
  return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
403
407
  return archive_after_turn if local == :archive && (@running || !@queued_ids.empty?)
404
408
  return exit_and_archive if local == :archive
405
409
 
406
410
  if @continue_offer
407
- return answer_continue(text) unless local || command_registry.command?(text)
411
+ return answer_continue(text) unless local || route.kind == :command
408
412
 
409
413
  # Not an answer: the read left no echo, so show what ran.
410
414
  echo_answer(text)
411
415
  end
412
- return if text.empty?
416
+ return if route.kind == :empty
413
417
 
414
418
  if local == :stats
415
419
  show_stats
416
420
  elsif local == :recap
417
421
  show_recap
418
- elsif command_registry.command?(text)
422
+ elsif route.kind == :command
419
423
  # The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
420
424
  persist_recent_history(text) if PromptHistory.shell_line?(text)
421
425
  send_command(text)
422
- elsif (hint = command_registry.unknown_command_hint(text))
426
+ elsif route.kind == :typo
423
427
  # A typo of a command (/modle) is not a prompt: say so, send nothing.
424
- @screen.commit(hint)
428
+ @screen.commit(route.reply)
425
429
  else
426
430
  send_prompt(text)
427
431
  end
428
432
  nil
429
433
  end
430
434
 
431
- # /cut TEXT and /queue TEXT while a turn runs. Only /cut goes in P1:
432
- # the worker's drain doesn't skip queued files yet, so a queued
433
- # message would steer the running turn; /queue says so and sends
434
- # nothing. At the prompt both run the text as a normal turn.
435
+ # /cut TEXT and /queue TEXT (+route+ has a choice). During a turn the
436
+ # text goes with its delivery: a cut, or queued to run as a turn of
437
+ # its own after this one. A worker that doesn't queue (its sidecar
438
+ # names no "delivery" feature) would steer a queue, so it is not sent.
439
+ # At the prompt both send the text as typed, a normal turn: no local
440
+ # word or typo check (the worker's Bridge runs a session command as
441
+ # the command).
442
+ # @param route [ComposerRoute::Route]
435
443
  # @return [nil]
436
- def submit_delivery(choice, text)
437
- return @screen.commit(STEER_CHOICE_USAGE) if text.empty?
438
- return @screen.commit(QUEUE_UNAVAILABLE) if choice == :queue && @running
444
+ def submit_delivery(route)
445
+ return @screen.commit(STEER_CHOICE_USAGE) if route.kind == :usage
446
+ return @screen.commit(QUEUE_NEEDS_NEWER_WORKER) if Delivery.queue?(route.delivery) && !worker_queues?
439
447
 
440
448
  # To an idle session it is an ordinary prompt: no delivery to send.
441
- send_prompt(text, delivery: @running ? Delivery::CUT : nil)
449
+ send_prompt(route.text, delivery: route.delivery)
442
450
  nil
443
451
  end
444
452
 
453
+ # Whether the attached worker runs delivery: queue (its sidecar names
454
+ # Delivery::QUEUE_FEATURE). No sidecar to read: true, its Bridge
455
+ # answers (a P0-P2 worker refuses a queue: #send_prompt).
456
+ def worker_queues?
457
+ sidecar = WorkerSidecar.read(Session.session_dir(@client.session_id, state_dir: @state_dir))
458
+ sidecar.nil? || sidecar.features.include?(Delivery::QUEUE_FEATURE)
459
+ end
460
+
445
461
  # @return [Symbol] :detach
446
462
  def detach(line)
447
463
  @view.finish_thinking_spinner
@@ -601,9 +617,9 @@ module Samagotchi
601
617
  end
602
618
 
603
619
  QUEUED_NOTE = "(queued: runs after this turn)"
604
- # /queue needs a worker whose drain skips queued input files (P3):
605
- # until then a queued message would steer the running turn.
606
- QUEUE_UNAVAILABLE = "(/queue isn't available in an attached session yet)"
620
+ QUEUE_RUNS_NOW = "(the turn had ended: runs as the next turn)"
621
+ # /queue to a worker from before queueing, which would steer it.
622
+ QUEUE_NEEDS_NEWER_WORKER = "(/queue needs a newer worker: restart it with chi sessions restart, then send again)"
607
623
 
608
624
  # A command queued for after the running turn (command_queued with
609
625
  # waits): its line now (another client's) and a note; its command_ran
@@ -709,14 +725,20 @@ module Samagotchi
709
725
  # What a prompt's 202 says became of it, or nil when there is nothing
710
726
  # to say: the dim line both TUIs print for a delivery. A plain line
711
727
  # for a running turn goes in at its next step; a cut's outcome comes
712
- # from the ack (the worker knows whether cutting is on for its model).
728
+ # from the ack (the worker knows whether cutting is on for its model);
729
+ # a queued one waits for the turn's end (the ack says queue).
713
730
  # An idle session's prompt simply runs, and a prompt with images runs
714
731
  # as its own next turn (never merging mid-turn), so nothing is said.
715
732
  # @param ack [Hash, nil] the Bridge's 202 body
716
733
  # @param delivery [String, nil] what was asked for
717
734
  # @return [String, nil]
718
735
  def sent_note(ack, delivery: nil, images: [], running: false)
719
- return nil unless running && images.empty? && ack.is_a?(Hash)
736
+ return nil unless running && ack.is_a?(Hash)
737
+ # The Bridge acks queue only while a turn ran at enqueue; a /queue
738
+ # it acks next_step found the turn over: it is the next turn.
739
+ return QUEUED_NOTE if ack["delivery"] == Delivery::QUEUE
740
+ return QUEUE_RUNS_NOW if Delivery.queue?(delivery)
741
+ return nil unless images.empty?
720
742
 
721
743
  return NEXT_STEP_NOTE unless delivery == Delivery::CUT
722
744
 
@@ -748,6 +770,8 @@ module Samagotchi
748
770
  detail = reply.json&.fetch("error", nil)
749
771
  # Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
750
772
  return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
773
+ # A worker from 0.51 to 0.63 refuses a queue (nothing was sent).
774
+ return @screen.commit(QUEUE_NEEDS_NEWER_WORKER) if detail == "delivery_unavailable"
751
775
 
752
776
  @screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})")
753
777
  rescue SystemCallError, IOError => e
@@ -1219,7 +1243,7 @@ module Samagotchi
1219
1243
  # A turn that ended with no answer: its notice, as live.
1220
1244
  empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
1221
1245
  if empty
1222
- @screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"]))
1246
+ @screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"], empty[:refusal] || empty["refusal"]))
1223
1247
  elsif failed
1224
1248
  # A failed turn whose steps stayed: the line the live view left,
1225
1249
  # and no step text as if it were the answer (the web keeps a
@@ -1359,6 +1383,8 @@ module Samagotchi
1359
1383
  def render_current_turn(turn)
1360
1384
  @running = !turn.nil?
1361
1385
  @joined_mid_turn = @running
1386
+ # A turn running at the join: the status row's steer hint.
1387
+ @status.update(running: @running)
1362
1388
  return unless turn
1363
1389
 
1364
1390
  continues = turn[:prompt].nil?
@@ -1411,7 +1437,8 @@ module Samagotchi
1411
1437
  return if own?(event[:client_id])
1412
1438
 
1413
1439
  @shown_enqueued << event[:enqueued_id]
1414
- @screen.commit(prompt_line(event[:client_id], event[:prompt]))
1440
+ line = prompt_line(event[:client_id], event[:prompt])
1441
+ @screen.commit(event[:delivery] == Delivery::QUEUE ? "#{line} #{QUEUED_NOTE}" : line)
1415
1442
  end
1416
1443
 
1417
1444
  def start_turn(event)
@@ -179,7 +179,8 @@ module Samagotchi
179
179
  end
180
180
  # No answer: chi's muted notice, never an empty answer line.
181
181
  if summary[:empty_answer]
182
- @view.print_line(@view.format_empty_answer_line(summary.dig(:empty_answer, :retries)))
182
+ empty = summary[:empty_answer]
183
+ @view.print_line(@view.format_empty_answer_line(empty[:retries], empty[:refusal]))
183
184
  else
184
185
  @view.print_line(summary[:output])
185
186
  end
@@ -108,14 +108,8 @@ module Samagotchi
108
108
  CUT_NOW_NOTE = "(cut in now)"
109
109
  CUT_WAITS_NOTE = "(cuts in once the thinking passes %d s)"
110
110
  CUT_OFF_NOTE = "(cutting is off; goes in at the next step)"
111
+ # A bare /cut or /queue (ComposerRoute's :usage).
111
112
  STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)"
112
- # The UIs' own words for a delivery, listed for Tab and /help only
113
- # (a local: entry, so no command or bundle may use the names).
114
- CUT_COMMAND = "/cut"
115
- QUEUE_COMMAND = "/queue"
116
- # The word, then any whitespace and the text (as the registry's
117
- # entries match them, so a line Tab or /help knows is never a prompt).
118
- STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
119
113
 
120
114
  # @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
121
115
  # @param after [Integer, nil] the run's cut wait (the model's own
@@ -133,17 +127,6 @@ module Samagotchi
133
127
  # The same words, for a UI that includes the module.
134
128
  def steer_cut_note(outcome, after = nil) = Formatting.steer_cut_note(outcome, after)
135
129
 
136
- # The choice a mid-turn line carries, as [:cut, "TEXT"] or
137
- # [:queue, "TEXT"]; nil for any other line. The text is "" when the
138
- # line named the choice without one.
139
- # @param line [String, nil]
140
- # @return [Array(Symbol, String), nil]
141
- def steer_choice(line)
142
- match = STEER_CHOICE.match(line.to_s.strip) or return nil
143
-
144
- [match[1] == CUT_COMMAND ? :cut : :queue, match[2].to_s.strip]
145
- end
146
-
147
130
  # "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
148
131
  # off)": a batch of LLM context edits went in (LLMContextNotice builds
149
132
  # the line; the web prints the same), dim like the retry row.
@@ -152,9 +135,10 @@ module Samagotchi
152
135
  end
153
136
 
154
137
  # "no answer: the model returned nothing (after 1 retry)", dim like the
155
- # retry row: a turn that ended with no answer (TurnNote.empty_answer_line).
156
- def format_empty_answer_line(retries)
157
- paint(TurnNote.empty_answer_line(retries), 90)
138
+ # retry row: a turn that ended with no answer, or "no answer: refused
139
+ # by the provider (cyber)" for a +refusal+ (TurnNote.empty_answer_line).
140
+ def format_empty_answer_line(retries, refusal = nil)
141
+ paint(TurnNote.empty_answer_line(retries, refusal: refusal), 90)
158
142
  end
159
143
 
160
144
  # "↻ retrying (503) in 4.0s, 2/5": the live status while the provider
@@ -363,7 +347,8 @@ module Samagotchi
363
347
 
364
348
  # A cancel's reason as the UIs name it (anything else as it is);
365
349
  # spec/shared/labels_matrix.json pins it for both.
366
- CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook", "worker_stopped" => "worker stopped" }.freeze
350
+ CANCEL_REASONS = { "ctrl_c" => "Ctrl-C", "user" => "stopped", "hook" => "by a hook", "worker_stopped" => "worker stopped",
351
+ "session_stopped" => "session stopped" }.freeze
367
352
  # A canceled turn's mark: a stop someone chose (Ctrl-C, Stop, a hook),
368
353
  # not an error's ✕ (timing.js STOP_MARK; the labels matrix pins it).
369
354
  STOP_MARK = "■"
@@ -28,7 +28,15 @@ module Samagotchi
28
28
  # preloaded its --memory list (shown until a turn
29
29
  # records them as used)
30
30
  # muted its --mute list
31
- FIELDS = %i[model default_model served parent_id children context used_memories preloaded muted].freeze
31
+ # running a turn runs: the row trails STEER_HINT
32
+ FIELDS = %i[model default_model served parent_id children context used_memories preloaded muted running].freeze
33
+
34
+ # The tail while a turn runs: where a typed line goes (the next step)
35
+ # and the two other choices. Dropped whole first when the row is cut.
36
+ STEER_HINT = " · typing goes in at the next step (/cut, /queue)"
37
+
38
+ # The turn events that end a turn (the hint goes).
39
+ TURN_END_EVENTS = %i[turn_completed turn_canceled turn_failed].freeze
32
40
 
33
41
  # @param surface [Surface] with #columns
34
42
  def initialize(surface)
@@ -77,13 +85,16 @@ module Samagotchi
77
85
 
78
86
  # Take what a turn event says about the row (the Engine's events in
79
87
  # the REPL, the Bridge's in attached mode): ctx, the memories used,
80
- # the model the server served. Other events change nothing.
88
+ # the model the server served, whether a turn runs (the steer
89
+ # hint). Other events change nothing.
81
90
  def take_event(event)
82
91
  case event[:type]
83
92
  when :context_status then update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
84
93
  when :used_memories_updated then update(used_memories: Array(event[:used_memory_names]))
85
94
  when :generation_completed
86
95
  update(served: [event[:served_model], event[:requested_model], event[:served_expected_by]]) if event[:served_model]
96
+ when :turn_started then update(running: true)
97
+ when *TURN_END_EVENTS then update(running: false)
87
98
  end
88
99
  end
89
100
 
@@ -119,11 +130,21 @@ module Samagotchi
119
130
  status_context_text(estimate: @values[:context]),
120
131
  status_memory_text(Array(@values[:used_memories]) | Array(@values[:preloaded]), MEMORY_LIMIT),
121
132
  status_memory_text(@values[:muted], MEMORY_LIMIT, label: "muted")].reject(&:empty?)
122
- status_rows(segments, width)
133
+ status_rows(with_steer_hint(segments, width), width)
123
134
  end
124
135
 
125
136
  private
126
137
 
138
+ # +segments+ with STEER_HINT after the last while a turn runs, when
139
+ # the whole row fits +width+ with it: on a narrow terminal the hint
140
+ # goes first, whole, before anything else is cut.
141
+ def with_steer_hint(segments, width)
142
+ return segments unless @values[:running] && segments.any?
143
+ return segments if "status> #{segments.join(" | ")}#{STEER_HINT}".length > width
144
+
145
+ segments[0..-2] + ["#{segments.last}#{STEER_HINT}"]
146
+ end
147
+
127
148
  # `⑂ 2 running · 1 waiting`, the counts there are; "" with neither (no
128
149
  # children, or all ended). Not "not reported": a REPL parent is never
129
150
  # given its children's replies (no wakes there), and a worker moves
@@ -25,6 +25,7 @@ require_relative "tools/memory"
25
25
  require_relative "output_formatter"
26
26
  require_relative "turn_flow"
27
27
  require_relative "session_commands"
28
+ require_relative "composer_route"
28
29
  require_relative "terminal_ui/attached_view"
29
30
  require_relative "terminal_ui/between_turns"
30
31
  require_relative "terminal_ui/event_renderer"
@@ -296,11 +297,11 @@ module Samagotchi
296
297
 
297
298
  if @prompt && @non_interactive
298
299
  # A /cut TEXT / /queue TEXT prompt runs its text as the turn's
299
- # prompt, the same as the idle REPL does (Formatting#steer_choice);
300
+ # prompt, the same as the idle REPL does (ComposerRoute.steer_choice);
300
301
  # a bare /cut / /queue (no text) is refused above as a UI command,
301
302
  # so only the text here. The images are the text's, not the words'.
302
303
  one_shot_prompt =
303
- if (choice = steer_choice(@prompt)) && !choice.last.empty?
304
+ if (choice = ComposerRoute.steer_choice(@prompt)) && !choice.last.empty?
304
305
  choice.last
305
306
  else
306
307
  @prompt
@@ -834,34 +835,35 @@ module Samagotchi
834
835
  return if input.empty?
835
836
 
836
837
  # /cut TEXT and /queue TEXT are deliveries only while a turn runs; at
837
- # the prompt both run the text as a normal turn, as their usage says.
838
- if (choice = steer_choice(input))
839
- return @surface.commit(STEER_CHOICE_USAGE) if choice.last.empty?
838
+ # the prompt both run the text as a normal turn, as their usage says
839
+ # (the route is the text's).
840
+ route = ComposerRoute.call(input, running: false, registry: command_registry)
841
+ return @surface.commit(STEER_CHOICE_USAGE) if route.kind == :usage
840
842
 
841
- input = choice.last
842
- end
843
+ input = route.text if route.choice
843
844
 
844
845
  # /model, /models, !rollback, !cmd, /continue (shared with workers);
845
846
  # an anytime command's cards print as it shows them (btw's
846
847
  # "thinking…" before the answer). Piped input has nobody to answer a
847
848
  # confirm (the next line is already written): a costly /thinking or
848
849
  # /reasoning-history applies at once.
849
- confirm = !piped_input?
850
- command = if command_registry.anytime?(input)
851
- @engine.running_anytime { @commands.run(input, confirm: confirm) }
852
- else
853
- @commands.run(input, confirm: confirm)
854
- end
855
- if command
856
- show_command_result(command)
857
- persist_recent_history(input) if command.shell
858
- return
859
- end
860
- return if show_local_command(input)
861
-
850
+ case route.kind
851
+ when :command
852
+ confirm = !piped_input?
853
+ command = if command_registry.anytime?(input)
854
+ @engine.running_anytime { @commands.run(input, confirm: confirm) }
855
+ else
856
+ @commands.run(input, confirm: confirm)
857
+ end
858
+ if command
859
+ show_command_result(command)
860
+ persist_recent_history(input) if command.shell
861
+ return
862
+ end
863
+ when :local then return if show_local_command(input)
862
864
  # A typo of a command (/modle) is not a prompt: say so, send nothing.
863
- hint = command_registry.unknown_command_hint(input)
864
- return @surface.commit(hint) if hint
865
+ when :typo then return @surface.commit(route.reply)
866
+ end
865
867
 
866
868
  @turn_flow.before_prompt_turn
867
869
  persist_recent_history(input)
@@ -1164,29 +1166,42 @@ module Samagotchi
1164
1166
  #
1165
1167
  # A command that waits for the turn's end (/model X, !cmd) and every line
1166
1168
  # after it wait in @after_turn, in the order typed (D1), with the image
1167
- # lines (which never steer): once the turn ends they come back after the
1168
- # steering it didn't merge (typed before them), ahead of anything sent
1169
- # after Ctrl-C.
1169
+ # lines (which never steer) and /queue's: once the turn ends they come
1170
+ # back with the steering it didn't merge, in the order sent (each line
1171
+ # the turn takes gets the next @input_seq), ahead of anything sent after
1172
+ # Ctrl-C.
1170
1173
  def with_steering(&)
1171
1174
  return yield unless @repl_input
1172
1175
 
1173
1176
  @after_turn = []
1174
1177
  @command_waits = false
1175
- @repl_input.during_turn(method(:steer_line), leftovers: -> { @pending_input_queue.drain + lines_after_turn }, &)
1178
+ @input_seq = 0
1179
+ @repl_input.during_turn(method(:steer_line), leftovers: -> { leftover_lines }, &)
1180
+ end
1181
+
1182
+ # The turn's leftovers in send order: steering it didn't merge and the
1183
+ # lines that waited for its end.
1184
+ def leftover_lines
1185
+ (@pending_input_queue.drain_entries + lines_after_turn).sort_by { |seq, _| seq || 0 }.map(&:last)
1176
1186
  end
1177
1187
 
1178
- # The lines that waited for the turn's end, taken from @after_turn. After
1179
- # a canceled or failed turn (Ctrl-C raises through here; a failure too)
1180
- # a !cmd among them is dropped (D4): running it would close the
1181
- # !rollback the turn left.
1188
+ # The next line's place in send order (#leftover_lines).
1189
+ def next_input_seq
1190
+ @input_seq = (@input_seq || 0) + 1
1191
+ end
1192
+
1193
+ # The [seq, line] entries that waited for the turn's end, taken from
1194
+ # @after_turn. After a canceled or failed turn (Ctrl-C raises through
1195
+ # here; a failure too) a !cmd among them is dropped (D4): running it
1196
+ # would close the !rollback the turn left.
1182
1197
  def lines_after_turn
1183
- lines = @after_turn.slice!(0..)
1198
+ entries = @after_turn.slice!(0..)
1184
1199
  ended = if @active_cancel_controller&.cancelled? || $!.is_a?(Interrupt) then "canceled"
1185
1200
  elsif $! then "failed"
1186
1201
  end
1187
- return lines unless ended
1202
+ return entries unless ended
1188
1203
 
1189
- lines.reject do |line|
1204
+ entries.reject do |_seq, line|
1190
1205
  next false unless command_registry.lookup(line)&.id == :shell
1191
1206
 
1192
1207
  @surface.commit("turn #{ended}: #{line.strip} not run; send it again")
@@ -1207,28 +1222,32 @@ module Samagotchi
1207
1222
  # to end and runs as a turn of its own.
1208
1223
  # @return [Boolean, :back] whether the turn took it, :back to put it back
1209
1224
  def steer_line(line)
1210
- local = local_command(line) unless line.nil?
1225
+ route = ComposerRoute.call(line, running: true, registry: command_registry) unless line.nil?
1226
+ local = route&.local
1211
1227
  return exit_after_turn(delete: SessionCommands.delete_on_exit?(line)) if line.nil? || local == :exit
1212
1228
  return archive_after_turn if local == :archive
1213
1229
  return detach_note if local == :detach
1214
1230
  return false if @active_cancel_controller&.cancelled?
1215
1231
 
1216
- # Before every command check: /cut and /queue are the UIs' own words
1217
- # for a delivery (they are listed for Tab and /help, never run).
1218
- if (choice = steer_choice(line))
1219
- return submit_steer_choice(*choice)
1232
+ case route.kind
1233
+ # Before every command check (ComposerRoute): /cut and /queue are the
1234
+ # UIs' own words for a delivery (listed for Tab and /help, never run).
1235
+ when :usage
1236
+ @surface.commit(STEER_CHOICE_USAGE)
1237
+ return true
1238
+ when :deliver then return submit_steer_choice(route.delivery, route.text)
1239
+ when :command
1240
+ policy = command_registry.mid_turn(line)
1241
+ return start_anytime_command(line) if policy == :anytime
1242
+ return command_queued(line) if policy == :queue
1220
1243
  end
1221
-
1222
- policy = command_registry.mid_turn(line)
1223
- return start_anytime_command(line) if policy == :anytime
1224
- return command_queued(line) if policy == :queue
1225
1244
  # A command, never steering text.
1226
- return command_during_turn(line) if local || command_registry.command?(line)
1227
- return true if line.strip.empty?
1245
+ return command_during_turn(line) if %i[local command].include?(route.kind)
1246
+ return true if route.kind == :empty
1228
1247
 
1229
1248
  # A typo of a command (/modle) is not steering text: say so, take it.
1230
- if (hint = command_registry.unknown_command_hint(line))
1231
- @surface.commit(hint)
1249
+ if route.kind == :typo
1250
+ @surface.commit(route.reply)
1232
1251
  return true
1233
1252
  end
1234
1253
  # Steering merges text only: a line with images runs as the next turn.
@@ -1236,7 +1255,7 @@ module Samagotchi
1236
1255
  # Behind a command that waits for the turn's end, a line waits too.
1237
1256
  return wait_for_turn_end(line) if @command_waits
1238
1257
 
1239
- @pending_input_queue.push(line.strip)
1258
+ @pending_input_queue.push(route.text, seq: next_input_seq)
1240
1259
  @surface.commit(NEXT_STEP_NOTE)
1241
1260
  true
1242
1261
  end
@@ -1245,25 +1264,21 @@ module Samagotchi
1245
1264
  # otherwise read as prompts (SessionCommands registers them for Tab and
1246
1265
  # help only). A cut asks the engine to cut the generation only thinking
1247
1266
  # too long; a queue waits for the turn's end (the REPL's own after-turn
1248
- # list). Empty text gets the usage line. A line with images never steers
1249
- # (as a plain one doesn't).
1267
+ # list). A line with images never steers (as a plain one doesn't).
1268
+ # @param delivery [String] Delivery::CUT or Delivery::QUEUE (ComposerRoute)
1250
1269
  # @return [Boolean] whether the turn took it
1251
- def submit_steer_choice(choice, text)
1252
- if text.empty?
1253
- @surface.commit(STEER_CHOICE_USAGE)
1254
- return true
1255
- end
1270
+ def submit_steer_choice(delivery, text)
1256
1271
  return image_line_waits(text) if ImageInput.extract(text).any?
1257
1272
 
1258
1273
  # A /queue waits for the turn's end; a /cut behind a command that
1259
1274
  # already waits there goes after it too.
1260
- if choice == :queue || @command_waits
1275
+ if Delivery.queue?(delivery) || @command_waits
1261
1276
  @surface.commit(COMMAND_QUEUED)
1262
1277
  return wait_for_turn_end(text)
1263
1278
  end
1264
1279
 
1265
1280
  epoch = @engine.input_epoch
1266
- @pending_input_queue.push(text)
1281
+ @pending_input_queue.push(text, seq: next_input_seq)
1267
1282
  # With no session running the cut is :off; the text still waits for
1268
1283
  # the running turn's next step.
1269
1284
  @surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch), @engine.steer_cut_after))
@@ -1278,7 +1293,7 @@ module Samagotchi
1278
1293
  # +line+ runs once the turn has ended, in the order typed (#with_steering).
1279
1294
  # @return [true] the turn took it
1280
1295
  def wait_for_turn_end(line)
1281
- @after_turn << line
1296
+ @after_turn << [next_input_seq, line]
1282
1297
  true
1283
1298
  end
1284
1299
 
@@ -211,14 +211,14 @@ module Samagotchi
211
211
  # "stopped" for a
212
212
  # task_wait the user's Stop ended (the task itself runs on), for one
213
213
  # whose task was stopped (the stop-task button, or the model's task_stop),
214
- # and for a command the user's Stop (or a signal to chi) killed or kept
215
- # from starting: a cancel, not a failure (its text still starts "Error:"
216
- # for the model).
214
+ # and for a command the user's Stop (or a signal to chi, a session stop)
215
+ # killed or kept from starting: a cancel, not a failure (its text still
216
+ # starts "Error:" for the model).
217
217
  def tool_activity_status(result, tool_name = nil, command: nil)
218
218
  text = result.to_s
219
- return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, Tools::Execute::NOT_RUN_ON_SIGNAL].include?(text)
219
+ return "stopped" if [Tools::Execute::NOT_RUN_ON_STOP, *Tools::Execute::NOT_RUN.values].include?(text)
220
220
  if tool_name == Tools::Execute::NAME &&
221
- text.start_with?(Tools::Execute::STOPPED_BY_USER, Tools::Execute::STOPPED_BY_SIGNAL)
221
+ text.start_with?(Tools::Execute::STOPPED_BY_USER, *Tools::Execute::STOPPED_BY.values)
222
222
  return "stopped"
223
223
  end
224
224
  return "error" if text.start_with?("Error:")
@@ -203,7 +203,9 @@ module Samagotchi
203
203
  delivered = SessionManager.deliver_turn(id, prompt: task, client_id: "#{ClientId::DELEGATE_PREFIX}#{parent.id[0, 8]}",
204
204
  state_dir: state_dir)
205
205
  case delivered[:status]
206
- when :accepted then id
206
+ when :accepted
207
+ DelegateWait.mark_turn(parent.id, id, follow_up_turn(id, delivered[:ack], state_dir: state_dir), state_dir: state_dir)
208
+ id
207
209
  when :refused then "Error: session #{id[0, 8]} refused the message (#{delivered.dig(:ack, "error")})"
208
210
  when :timeout then "Error: the worker of session #{id[0, 8]} did not answer in time, so the message was not sent"
209
211
  else "Error: the message to session #{id[0, 8]} could not be written"
@@ -215,6 +217,31 @@ module Samagotchi
215
217
  end
216
218
  private_class_method :follow_up
217
219
 
220
+ # The child's turn a delivered follow-up joins, for the wait
221
+ # (DelegateCursor#turn): the running turn's (its checkpoint mark), the
222
+ # follow-up merging into it; else the ack's enqueued_id, the follow-up
223
+ # running as a turn of its own. Only from a worker that names
224
+ # turn_ids; nil otherwise, or when the running turn has no id (a
225
+ # delegate's first turn): the wait takes the next reply.
226
+ # A follow-up steered into a turn that ended before it merged runs as
227
+ # its own turn, yet the wait is on the ended one (LOW, as before).
228
+ # One the child's idle look missed being merged into a turn starting
229
+ # then never runs as its own: ReplyWait falls back to that turn's
230
+ # reply (merged_elsewhere?).
231
+ # @return [String, nil]
232
+ def self.follow_up_turn(id, ack, state_dir:)
233
+ return nil unless ReplyWait.turn_ids?(Session.session_dir(id, state_dir: state_dir))
234
+
235
+ child = Session.load(id, state_dir: state_dir)
236
+ return child.interrupted_turn&.enqueued_id if child.status == Session::STATUS_RUNNING
237
+
238
+ enqueued_id = ack.is_a?(Hash) ? (ack["enqueued_id"] || ack[:enqueued_id]) : nil
239
+ enqueued_id.to_s.empty? ? nil : enqueued_id.to_s
240
+ rescue ArgumentError
241
+ nil
242
+ end
243
+ private_class_method :follow_up_turn
244
+
218
245
  # The model as typed (spawn_session stores its resolved ref), else the
219
246
  # parent's. No existence check here: spawn_session checks the host
220
247
  # (check_host!, its UnknownHost comes back as this tool's Error: line)
@@ -10,9 +10,14 @@ module Samagotchi
10
10
  # What a parent was already told about one delegate child: the newest
11
11
  # reply it was given (+reply_file+, ReplyWait's cursor) and the child as
12
12
  # it was then (+messages+, +question_id+, +last_turn+: ReplyWait's
13
- # baseline). nil +messages+ means no baseline.
14
- DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages) do
15
- def self.empty = new(reply_file: nil, last_turn: nil, question_id: nil, messages: nil)
13
+ # baseline). nil +messages+ means no baseline. +turn+: the enqueued_id
14
+ # of the child's turn the last follow-up went to (Delegate.follow_up),
15
+ # whose own reply a wait takes (ReplyWait's turn:); nil once that turn
16
+ # was reported, or when unknown (a new child, an older worker).
17
+ DelegateCursor = Data.define(:reply_file, :last_turn, :question_id, :messages, :turn) do
18
+ def initialize(reply_file: nil, last_turn: nil, question_id: nil, messages: nil, turn: nil) = super
19
+
20
+ def self.empty = new
16
21
 
17
22
  # @param baseline [Hash, nil] ReplyWait.baseline_of's
18
23
  def with_baseline(baseline)
@@ -93,7 +98,7 @@ module Samagotchi
93
98
  return DelegateCursor.empty unless data.is_a?(Hash)
94
99
 
95
100
  DelegateCursor.new(reply_file: data["reply_file"], last_turn: data["last_turn"],
96
- question_id: data["question_id"], messages: data["messages"])
101
+ question_id: data["question_id"], messages: data["messages"], turn: data["turn"])
97
102
  end
98
103
 
99
104
  def path(parent_id, state_dir:)