samagotchi 0.124.0 → 0.126.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +53 -1
- data/docs/architecture.md +4 -2
- data/docs/cli.md +20 -8
- data/docs/guardrails.md +22 -0
- data/docs/internals/participants.md +96 -3
- data/docs/internals/remote-sessions.md +2 -0
- data/docs/internals/web-push.md +10 -6
- data/docs/sessions.md +5 -2
- data/lib/samagotchi/bridge.rb +4 -3
- data/lib/samagotchi/bridge_client.rb +8 -1
- data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +25 -4
- data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_reports.rb +3 -0
- data/lib/samagotchi/children_status.rb +6 -1
- data/lib/samagotchi/engine.rb +43 -7
- data/lib/samagotchi/guardrail_wiring.rb +10 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -0
- data/lib/samagotchi/process_group.rb +10 -1
- data/lib/samagotchi/reminder_carry.rb +92 -0
- data/lib/samagotchi/reminder_store.rb +49 -0
- data/lib/samagotchi/session.rb +40 -3
- data/lib/samagotchi/session_inbox.rb +14 -8
- data/lib/samagotchi/session_manager.rb +39 -8
- data/lib/samagotchi/session_participants.rb +10 -2
- data/lib/samagotchi/sessions_command.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop/composer.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/exit_commands.rb +138 -0
- data/lib/samagotchi/terminal_ui/attached_loop/questions.rb +225 -0
- data/lib/samagotchi/terminal_ui/attached_loop/queueing.rb +154 -0
- data/lib/samagotchi/terminal_ui/attached_loop/sending.rb +302 -0
- data/lib/samagotchi/terminal_ui/attached_loop.rb +16 -1253
- data/lib/samagotchi/terminal_ui/exit_lines.rb +141 -0
- data/lib/samagotchi/terminal_ui/join_replay.rb +235 -0
- data/lib/samagotchi/verdict.rb +20 -0
- data/lib/samagotchi/verify_anchor.rb +28 -0
- data/lib/samagotchi/verify_fresh.rb +275 -0
- data/lib/samagotchi/verify_fresh_origin.rb +71 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +10 -3
- data/lib/samagotchi/web/participant_access.rb +15 -4
- data/lib/samagotchi/web/participant_review.rb +7 -1
- data/lib/samagotchi/web/participant_view.rb +46 -5
- data/lib/samagotchi/web/participants_admin.rb +15 -4
- data/lib/samagotchi/web/public/annotate_bar.css +8 -4
- data/lib/samagotchi/web/public/annotate_bar.js +43 -7
- data/lib/samagotchi/web/public/app.js +134 -8
- data/lib/samagotchi/web/public/claim_link.js +347 -0
- data/lib/samagotchi/web/public/data.js +2 -1
- data/lib/samagotchi/web/public/index.html +1 -0
- data/lib/samagotchi/web/public/notify.js +13 -2
- data/lib/samagotchi/web/public/participant.html +3 -1
- data/lib/samagotchi/web/public/participant.js +113 -9
- data/lib/samagotchi/web/public/participants_panel.js +15 -0
- data/lib/samagotchi/web/public/route.js +3 -2
- data/lib/samagotchi/web/public/send_turn.js +6 -1
- data/lib/samagotchi/web/public/session_state.js +5 -0
- data/lib/samagotchi/web/public/stream_handlers.js +3 -0
- data/lib/samagotchi/web/public/tokens.css +2 -1
- data/lib/samagotchi/web/public/verdict_card.js +65 -0
- data/lib/samagotchi/web/public/verdict_fresh.js +120 -0
- data/lib/samagotchi/web/public/verify_card.css +17 -0
- data/lib/samagotchi/web/public/verify_preset.js +18 -0
- data/lib/samagotchi/web/push/attention.rb +25 -5
- data/lib/samagotchi/web/push/notifier.rb +4 -3
- data/lib/samagotchi/web/session_control.rb +2 -0
- data/lib/samagotchi/web/session_summary.rb +25 -1
- data/lib/samagotchi/web/verify_fresh_routes.rb +110 -0
- data/lib/samagotchi/worker.rb +125 -12
- data/lib/samagotchi/worker_idle_exit.rb +7 -6
- data/lib/samagotchi/worker_inbound.rb +4 -4
- metadata +19 -1
|
@@ -13,6 +13,13 @@ require_relative "input_support"
|
|
|
13
13
|
require_relative "image_input"
|
|
14
14
|
require_relative "line_reader"
|
|
15
15
|
require_relative "question_prompt"
|
|
16
|
+
require_relative "join_replay"
|
|
17
|
+
require_relative "exit_lines"
|
|
18
|
+
require_relative "attached_loop/exit_commands"
|
|
19
|
+
require_relative "attached_loop/questions"
|
|
20
|
+
require_relative "attached_loop/queueing"
|
|
21
|
+
require_relative "attached_loop/composer"
|
|
22
|
+
require_relative "attached_loop/sending"
|
|
16
23
|
require_relative "reline_seam"
|
|
17
24
|
require_relative "version_lines"
|
|
18
25
|
require_relative "gap_clock"
|
|
@@ -48,49 +55,16 @@ module Samagotchi
|
|
|
48
55
|
class AttachedLoop
|
|
49
56
|
include Formatting
|
|
50
57
|
include InputSupport
|
|
58
|
+
include ExitCommands
|
|
59
|
+
include Questions
|
|
60
|
+
include Queueing
|
|
61
|
+
include Composer
|
|
62
|
+
include Sending
|
|
51
63
|
|
|
52
64
|
PROMPT = "> "
|
|
53
65
|
# A second Ctrl-C at an empty idle prompt within this many seconds detaches.
|
|
54
66
|
DETACH_WINDOW = 2.0
|
|
55
67
|
DETACH_HINT = "Ctrl-D to detach, /exit stops the worker"
|
|
56
|
-
# Why a request the Bridge read after its deadline didn't go through.
|
|
57
|
-
LATE = "worker not answering in time"
|
|
58
|
-
# An exit the worker agreed to in an empty session. Said before it has
|
|
59
|
-
# left: it checks again once it lets go, and a note or prompt arriving
|
|
60
|
-
# first keeps the session (SessionManager.discard_left_session).
|
|
61
|
-
EMPTY_WORDS = "the session is empty: it will be discarded if nothing arrives before its worker stops."
|
|
62
|
-
EMPTY_EXIT = "Detached; #{EMPTY_WORDS}"
|
|
63
|
-
# Why the worker stayed up after /exit, by the reason it gave.
|
|
64
|
-
HELD_REASONS = {
|
|
65
|
-
"turn_running" => "a turn is running", "input_queued" => "prompts are queued",
|
|
66
|
-
"continue_offered" => "a continue offer is pending", "client_connected" => "another UI is attached",
|
|
67
|
-
"reminders" => "reminders are set", "starting" => "the worker is still starting",
|
|
68
|
-
"background_tasks" => "background tasks it started are still running",
|
|
69
|
-
"delegates" => "delegates of this session are still running"
|
|
70
|
-
}.freeze
|
|
71
|
-
# How to end a background task that holds the worker up (an older
|
|
72
|
-
# worker's 409 has no hint of its own).
|
|
73
|
-
TASK_STOP_HINT = "stop one with the web's stop-task button or by asking for task_stop"
|
|
74
|
-
# The /exit ask, when every hold is one the user can stop: what still
|
|
75
|
-
# runs, then the three answers. Each kind, singular and plural.
|
|
76
|
-
EXIT_HOLD_WORDS = {
|
|
77
|
-
"reminders" => %w[reminder reminders],
|
|
78
|
-
"background_tasks" => ["background task", "background tasks"],
|
|
79
|
-
"delegates" => %w[delegate delegates]
|
|
80
|
-
}.freeze
|
|
81
|
-
# The 200's +stopped+ keys, by the kind the ask lists them under.
|
|
82
|
-
STOPPED_KEYS = { "reminders" => "reminders", "tasks" => "background_tasks", "delegates" => "delegates" }.freeze
|
|
83
|
-
EXIT_ASK_OPTIONS = ["Keep running in the background (detach)", "Stop them and exit", "Back to the session"].freeze
|
|
84
|
-
# How the ask reads an answer.
|
|
85
|
-
EXIT_ANSWERS = {
|
|
86
|
-
"1" => :keep, "keep" => :keep, "detach" => :keep, "background" => :keep,
|
|
87
|
-
"2" => :stop, "stop" => :stop, "exit" => :stop,
|
|
88
|
-
"3" => :back, "back" => :back, "stay" => :back
|
|
89
|
-
}.freeze
|
|
90
|
-
# How much of the last answer a join shows.
|
|
91
|
-
JOIN_ANSWER_LINES = 12
|
|
92
|
-
JOIN_ANSWER_CHARS = 1200
|
|
93
|
-
|
|
94
68
|
attr_reader :client_id
|
|
95
69
|
|
|
96
70
|
# @return [String, nil] the model the worker's turns run on, as the
|
|
@@ -175,6 +149,7 @@ module Samagotchi
|
|
|
175
149
|
@clock = clock
|
|
176
150
|
# The session's last activity, for the gap dividers (GapClock).
|
|
177
151
|
@gaps = GapClock.new(now: now)
|
|
152
|
+
@join_replay = JoinReplay.new(screen: @screen, view: @view, link: @link, gaps: @gaps, renderer: @renderer)
|
|
178
153
|
@last_idle_interrupt_at = nil
|
|
179
154
|
@next_input_prefill = nil
|
|
180
155
|
# Prompts this run sent, by enqueued_id: only those come back into
|
|
@@ -409,19 +384,6 @@ module Samagotchi
|
|
|
409
384
|
@screen.commit(gap_divider(line)) if line
|
|
410
385
|
end
|
|
411
386
|
|
|
412
|
-
# The divider above a typed line's echo (RelineSeam.echo_above, on the
|
|
413
|
-
# reader thread): a prompt typed after a quiet gap, at an idle prompt.
|
|
414
|
-
# It marks the activity, so the turn's start marks no second one.
|
|
415
|
-
def gap_above_echo(text)
|
|
416
|
-
return nil if @running
|
|
417
|
-
|
|
418
|
-
route = ComposerRoute.call(text.to_s.strip, running: false, registry: command_registry)
|
|
419
|
-
return nil unless %i[prompt deliver].include?(route.kind)
|
|
420
|
-
|
|
421
|
-
line = @gaps.divider
|
|
422
|
-
line && gap_divider(line)
|
|
423
|
-
end
|
|
424
|
-
|
|
425
387
|
# The newest chi installed, looked at once, at the join.
|
|
426
388
|
def installed_version
|
|
427
389
|
@link.installed_version
|
|
@@ -493,387 +455,6 @@ module Samagotchi
|
|
|
493
455
|
nil
|
|
494
456
|
end
|
|
495
457
|
|
|
496
|
-
# @return [Symbol, nil] :detach to end the loop
|
|
497
|
-
def submit(line)
|
|
498
|
-
if @exit_ask
|
|
499
|
-
# The exit ask is open: this line answers it. Ctrl-D and /detach
|
|
500
|
-
# detach without stopping anything, the way its 1 does.
|
|
501
|
-
return detach(kept_line(@exit_ask)) if line.nil? || command_registry.lookup_local(line.strip)&.id == :detach
|
|
502
|
-
|
|
503
|
-
return answer_exit_ask(line)
|
|
504
|
-
end
|
|
505
|
-
|
|
506
|
-
return detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if line.nil?
|
|
507
|
-
|
|
508
|
-
text = line.strip
|
|
509
|
-
if @question
|
|
510
|
-
# /detach leaves the question open, as Ctrl-D does; any other
|
|
511
|
-
# line (/exit too) is read as the answer.
|
|
512
|
-
return submit(nil) if command_registry.lookup_local(text)&.id == :detach
|
|
513
|
-
|
|
514
|
-
return answer_question(text)
|
|
515
|
-
end
|
|
516
|
-
|
|
517
|
-
text, kept = split_prefilled(text)
|
|
518
|
-
submitted = submit_line(text)
|
|
519
|
-
# The removed text the command was typed after, back in the input
|
|
520
|
-
# (unless an /unqueue put its own there, or the loop ends). Set
|
|
521
|
-
# before the read restarts with it: the read's echo reads it.
|
|
522
|
-
if kept && submitted != :detach && @prefilled.nil?
|
|
523
|
-
@prefilled = kept
|
|
524
|
-
@prefilled = nil unless @reader&.prefill(kept)
|
|
525
|
-
end
|
|
526
|
-
submitted
|
|
527
|
-
end
|
|
528
|
-
|
|
529
|
-
# A line typed after an /unqueue's text (#unqueued) without clearing
|
|
530
|
-
# it, the text untouched and then a command ("fix it/unqueue"): the
|
|
531
|
-
# command alone, and the text to put back. Any other line as is.
|
|
532
|
-
# @return [Array(String, String|nil)]
|
|
533
|
-
def split_prefilled(text)
|
|
534
|
-
kept = @prefilled
|
|
535
|
-
@prefilled = nil
|
|
536
|
-
rest = command_after(text, kept)
|
|
537
|
-
rest ? [rest, kept] : [text, nil]
|
|
538
|
-
end
|
|
539
|
-
|
|
540
|
-
# The command typed after +kept+ (/unqueue's text) in +text+, or nil.
|
|
541
|
-
# @return [String, nil]
|
|
542
|
-
def command_after(text, kept)
|
|
543
|
-
return nil unless kept && text.length > kept.length && text.start_with?(kept)
|
|
544
|
-
|
|
545
|
-
rest = text[kept.length..].strip
|
|
546
|
-
route = ComposerRoute.call(rest, running: @running, registry: command_registry)
|
|
547
|
-
rest if route.choice || %i[local command usage].include?(route.kind)
|
|
548
|
-
end
|
|
549
|
-
|
|
550
|
-
# The read's echo (RelineSeam.echo_as, on the reader thread): the
|
|
551
|
-
# command alone when one is typed after /unqueue's text, as
|
|
552
|
-
# #split_prefilled runs it; nil echoes the line as typed.
|
|
553
|
-
def prefilled_echo(typed) = command_after(typed.strip, @prefilled)
|
|
554
|
-
|
|
555
|
-
# @return [Symbol, nil] :detach to end the loop
|
|
556
|
-
def submit_line(text)
|
|
557
|
-
# /cut TEXT and /queue TEXT are this UI's own deliveries, read before
|
|
558
|
-
# every command check (ComposerRoute; SessionCommands registers them
|
|
559
|
-
# for Tab and /help only). A plain line sends no delivery: it goes in
|
|
560
|
-
# at the running turn's next step, and never cuts.
|
|
561
|
-
route = ComposerRoute.call(text, running: @running, registry: command_registry)
|
|
562
|
-
return submit_delivery(route) if route.choice
|
|
563
|
-
|
|
564
|
-
# The terminal's own commands (SessionCommands registers them, the
|
|
565
|
-
# REPL reads the same words). Before the continue offer: the
|
|
566
|
-
# leaving ones are no answer to it.
|
|
567
|
-
local = route.local
|
|
568
|
-
return submit(nil) if local == :detach
|
|
569
|
-
return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
|
|
570
|
-
return archive_after_turn if local == :archive && (@running || !@queued_ids.empty?)
|
|
571
|
-
return exit_and_archive if local == :archive
|
|
572
|
-
|
|
573
|
-
if @continue_offer
|
|
574
|
-
return answer_continue(text) unless local || route.kind == :command
|
|
575
|
-
|
|
576
|
-
# Not an answer: the read left no echo, so show what ran.
|
|
577
|
-
echo_answer(text)
|
|
578
|
-
end
|
|
579
|
-
return if route.kind == :empty
|
|
580
|
-
|
|
581
|
-
if local == :stats
|
|
582
|
-
show_stats
|
|
583
|
-
elsif local == :recap
|
|
584
|
-
show_recap
|
|
585
|
-
elsif local == :unqueue
|
|
586
|
-
unqueue_newest
|
|
587
|
-
elsif %i[share participants].include?(local)
|
|
588
|
-
@screen.commit(participant_commands.run(text))
|
|
589
|
-
elsif route.kind == :command
|
|
590
|
-
# The history keeps !cmds, as the REPL's does (prompts: #send_prompt).
|
|
591
|
-
persist_recent_history(text) if PromptHistory.shell_line?(text)
|
|
592
|
-
send_command(text)
|
|
593
|
-
elsif route.kind == :typo
|
|
594
|
-
# A typo of a command (/modle) is not a prompt: say so, send nothing.
|
|
595
|
-
@screen.commit(route.reply)
|
|
596
|
-
else
|
|
597
|
-
send_prompt(text)
|
|
598
|
-
end
|
|
599
|
-
nil
|
|
600
|
-
end
|
|
601
|
-
|
|
602
|
-
# /cut TEXT and /queue TEXT (+route+ has a choice). During a turn the
|
|
603
|
-
# text goes with its delivery: a cut, or queued to run as a turn of
|
|
604
|
-
# its own after this one. A worker that doesn't queue (its sidecar
|
|
605
|
-
# names no "delivery" feature) would steer a queue, so it is not sent.
|
|
606
|
-
# At the prompt both send the text as typed, a normal turn: no local
|
|
607
|
-
# word or typo check (the worker's Bridge runs a session command as
|
|
608
|
-
# the command).
|
|
609
|
-
# @param route [ComposerRoute::Route]
|
|
610
|
-
# @return [nil]
|
|
611
|
-
def submit_delivery(route)
|
|
612
|
-
return @screen.commit(STEER_CHOICE_USAGE) if route.kind == :usage
|
|
613
|
-
return @screen.commit(queue_needs_newer_worker) if Delivery.queue?(route.delivery) && !worker_queues?
|
|
614
|
-
|
|
615
|
-
# To an idle session it is an ordinary prompt: no delivery to send,
|
|
616
|
-
# and its ack says it was sent now (#sent_note).
|
|
617
|
-
send_prompt(route.text, delivery: route.delivery, choice: route.choice)
|
|
618
|
-
nil
|
|
619
|
-
end
|
|
620
|
-
|
|
621
|
-
# Whether the attached worker runs delivery: queue (its sidecar names
|
|
622
|
-
# Delivery::QUEUE_FEATURE). No sidecar to read: true, its Bridge
|
|
623
|
-
# answers (a P0-P2 worker refuses a queue: #send_prompt).
|
|
624
|
-
def worker_queues? = @link.queues?
|
|
625
|
-
|
|
626
|
-
# @return [Symbol] :detach
|
|
627
|
-
def detach(line)
|
|
628
|
-
@view.finish_thinking_spinner
|
|
629
|
-
@screen.commit(line)
|
|
630
|
-
:detach
|
|
631
|
-
end
|
|
632
|
-
|
|
633
|
-
# Detach, and ask the worker to exit now. The worker decides: it stays
|
|
634
|
-
# up while anything still needs it (a turn, queued input, a continue
|
|
635
|
-
# offer, another UI, reminders), and the line says which. When the only
|
|
636
|
-
# holds are ones the user can stop, it asks what to do with them instead
|
|
637
|
-
# of detaching (#answer_exit_ask).
|
|
638
|
-
# @return [Symbol, nil] :detach, or nil to stay at the prompt
|
|
639
|
-
def exit_worker
|
|
640
|
-
reply = @client.request_exit(client_id: @client_id)
|
|
641
|
-
holds = reply.status == 409 ? reply.json&.fetch("holds", nil) : nil
|
|
642
|
-
return detach(exit_line(reply)) unless holds.is_a?(Array) && !holds.empty?
|
|
643
|
-
|
|
644
|
-
@exit_ask = holds
|
|
645
|
-
@screen.commit(ask_lines(holds))
|
|
646
|
-
nil
|
|
647
|
-
rescue SystemCallError, IOError => e
|
|
648
|
-
detach(exit_failed_line(e.message))
|
|
649
|
-
end
|
|
650
|
-
|
|
651
|
-
# The ask: what still runs, then the three answers.
|
|
652
|
-
def ask_lines(holds)
|
|
653
|
-
lines = holds.map do |hold|
|
|
654
|
-
items = Array(hold["items"])
|
|
655
|
-
singular, plural = hold_words(hold["kind"])
|
|
656
|
-
" • #{items.size} #{items.size == 1 ? singular : plural}: #{items.map { |item| item_word(item, hold["kind"]) }.join(", ")}"
|
|
657
|
-
end
|
|
658
|
-
lines += EXIT_ASK_OPTIONS.each_with_index.map { |option, index| " #{index + 1}. #{option}" }
|
|
659
|
-
(["This session still has:"] + lines).join("\n")
|
|
660
|
-
end
|
|
661
|
-
|
|
662
|
-
# One entry of a hold: the command or name, with its detail in
|
|
663
|
-
# brackets; a delegate leads with its short id and its title in quotes.
|
|
664
|
-
def item_word(item, kind)
|
|
665
|
-
label = item["label"].to_s
|
|
666
|
-
label = "#{item["id"].to_s[0, 8]} \"#{label}\"" if kind == "delegates"
|
|
667
|
-
detail = item["detail"].to_s
|
|
668
|
-
detail.empty? ? label : "#{label} (#{detail})"
|
|
669
|
-
end
|
|
670
|
-
|
|
671
|
-
# The answer to the exit ask.
|
|
672
|
-
# @return [Symbol, nil] :detach for 1 and 2, nil for 3 or a line the
|
|
673
|
-
# ask doesn't understand
|
|
674
|
-
def answer_exit_ask(line)
|
|
675
|
-
choice = EXIT_ANSWERS[line.to_s.strip.downcase]
|
|
676
|
-
unless choice
|
|
677
|
-
@screen.commit("Say 1 (keep running), 2 (stop them and exit) or 3 (back to the session).")
|
|
678
|
-
return nil
|
|
679
|
-
end
|
|
680
|
-
|
|
681
|
-
holds = @exit_ask
|
|
682
|
-
@exit_ask = nil
|
|
683
|
-
case choice
|
|
684
|
-
when :keep then detach(kept_line(holds))
|
|
685
|
-
when :stop then stop_and_exit
|
|
686
|
-
else
|
|
687
|
-
@screen.commit("(nothing stopped; the worker keeps running)")
|
|
688
|
-
nil
|
|
689
|
-
end
|
|
690
|
-
end
|
|
691
|
-
|
|
692
|
-
# 1: detach, everything left running.
|
|
693
|
-
def kept_line(holds)
|
|
694
|
-
"Detached; the session keeps running (#{hold_words_list(holds)}). " \
|
|
695
|
-
"Re-attach with: #{via.attach(@client.session_id)}"
|
|
696
|
-
end
|
|
697
|
-
|
|
698
|
-
# 2: ask the worker to stop them all and exit.
|
|
699
|
-
# @return [Symbol] :detach
|
|
700
|
-
def stop_and_exit
|
|
701
|
-
reply = @client.request_exit(client_id: @client_id, stop: true)
|
|
702
|
-
detach(exit_line(reply))
|
|
703
|
-
rescue SystemCallError, IOError => e
|
|
704
|
-
detach(exit_failed_line(e.message))
|
|
705
|
-
end
|
|
706
|
-
|
|
707
|
-
# "2 reminders, 1 background task, 1 delegate" from the ask's holds.
|
|
708
|
-
def hold_words_list(holds)
|
|
709
|
-
holds.map do |hold|
|
|
710
|
-
items = Array(hold["items"])
|
|
711
|
-
singular, plural = hold_words(hold["kind"])
|
|
712
|
-
"#{items.size} #{items.size == 1 ? singular : plural}"
|
|
713
|
-
end.join(", ")
|
|
714
|
-
end
|
|
715
|
-
|
|
716
|
-
def hold_words(kind) = EXIT_HOLD_WORDS.fetch(kind.to_s, [kind.to_s, kind.to_s])
|
|
717
|
-
|
|
718
|
-
# What a stop-all exit stopped: "stopped 2 reminders (api_health, git
|
|
719
|
-
# push), 1 background task (rails s)"; nil when it stopped nothing.
|
|
720
|
-
def stopped_words(stopped)
|
|
721
|
-
return nil unless stopped.is_a?(Hash)
|
|
722
|
-
|
|
723
|
-
parts = STOPPED_KEYS.filter_map do |key, kind|
|
|
724
|
-
names = Array(stopped[key])
|
|
725
|
-
next if names.empty?
|
|
726
|
-
|
|
727
|
-
singular, plural = hold_words(kind)
|
|
728
|
-
"#{names.size} #{names.size == 1 ? singular : plural} (#{names.join(", ")})"
|
|
729
|
-
end
|
|
730
|
-
parts.empty? ? nil : "stopped #{parts.join(", ")}"
|
|
731
|
-
end
|
|
732
|
-
|
|
733
|
-
# /exit --delete: the worker exits as for /exit, then the session is
|
|
734
|
-
# deleted. When the worker stays up (another UI, queued input, ...),
|
|
735
|
-
# nothing is deleted.
|
|
736
|
-
def exit_and_delete
|
|
737
|
-
reply = @client.request_exit(client_id: @client_id, delete: true)
|
|
738
|
-
id = @client.session_id
|
|
739
|
-
unless reply.status == 200
|
|
740
|
-
line = exit_line(reply)
|
|
741
|
-
return detach(line.sub("Detached; the session keeps running", "Detached; not deleted: the session keeps running")) if reply.status == 409
|
|
742
|
-
|
|
743
|
-
return detach("#{line} Not deleted.")
|
|
744
|
-
end
|
|
745
|
-
# An empty session: the worker deletes it as it leaves.
|
|
746
|
-
return detach(EMPTY_EXIT) if discards?(reply)
|
|
747
|
-
|
|
748
|
-
@link.delete_session
|
|
749
|
-
detach("Detached; deleted session #{id}.")
|
|
750
|
-
rescue SystemCallError, IOError => e
|
|
751
|
-
detach("#{exit_failed_line(e.message)} Not deleted.")
|
|
752
|
-
rescue SessionManager::DeleteRefused, SessionManager::OwnedByTUI, ArgumentError => e
|
|
753
|
-
detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): " \
|
|
754
|
-
"#{via.command("sessions delete #{id}")}")
|
|
755
|
-
end
|
|
756
|
-
|
|
757
|
-
# /archive during a turn (D2): it waits for the turn's end and the
|
|
758
|
-
# commands queued behind it (#archive_due?), then runs.
|
|
759
|
-
# @return [nil]
|
|
760
|
-
def archive_after_turn
|
|
761
|
-
@archive_after_turn = true
|
|
762
|
-
@screen.commit("(archives after this turn)")
|
|
763
|
-
nil
|
|
764
|
-
end
|
|
765
|
-
|
|
766
|
-
def archive_due? = @archive_after_turn && !@running && @queued_ids.empty?
|
|
767
|
-
|
|
768
|
-
# @return [Symbol] :detached
|
|
769
|
-
def archive_now
|
|
770
|
-
@archive_after_turn = false
|
|
771
|
-
exit_and_archive
|
|
772
|
-
:detached
|
|
773
|
-
end
|
|
774
|
-
|
|
775
|
-
# /archive: the worker is asked to exit as for /exit, then the session
|
|
776
|
-
# is archived. A worker staying up for another UI (or reminders) is
|
|
777
|
-
# stopped by the archive, as the web's archive stops it; a turn
|
|
778
|
-
# running, a queued prompt or an open continue offer refuses it
|
|
779
|
-
# (SessionManager.archive_session). An empty session: the worker
|
|
780
|
-
# deletes it as it leaves.
|
|
781
|
-
def exit_and_archive
|
|
782
|
-
# /archive can't ask about what still runs: a child the archive will
|
|
783
|
-
# stop is not worth a question here.
|
|
784
|
-
reply = @client.request_exit(client_id: @client_id, ask: false)
|
|
785
|
-
id = @client.session_id
|
|
786
|
-
return detach(EMPTY_EXIT) if reply.status == 200 && discards?(reply)
|
|
787
|
-
return detach("#{exit_line(reply)} Not archived.") unless [200, 409].include?(reply.status)
|
|
788
|
-
|
|
789
|
-
result = @link.archive_session
|
|
790
|
-
return detach("Detached; the session was empty, so it is discarded.") if result[:discarded].include?(id)
|
|
791
|
-
|
|
792
|
-
detach("Detached; archived session #{id}. #{via.command("sessions list --archived")} finds it.")
|
|
793
|
-
rescue SystemCallError, IOError => e
|
|
794
|
-
detach("#{exit_failed_line(e.message)} Not archived.")
|
|
795
|
-
rescue SessionManager::ArchiveRefused, SessionManager::OwnedByTUI, ArgumentError => e
|
|
796
|
-
detach("Detached; session #{id} was not archived (#{e.message}). Re-attach with: #{via.attach(id)}")
|
|
797
|
-
end
|
|
798
|
-
|
|
799
|
-
def exit_line(reply)
|
|
800
|
-
id = @client.session_id
|
|
801
|
-
error = reply.json&.fetch("error", nil)
|
|
802
|
-
case reply.status
|
|
803
|
-
when 200
|
|
804
|
-
stopped = stopped_words(reply.json&.fetch("stopped", nil))
|
|
805
|
-
return EMPTY_EXIT if discards?(reply) && stopped.nil?
|
|
806
|
-
return "Detached; #{stopped}. #{EMPTY_WORDS.capitalize}" if discards?(reply)
|
|
807
|
-
|
|
808
|
-
"Detached; #{stopped || "the session's worker is stopping"}. Resume with: #{via.resume(id)}"
|
|
809
|
-
when 409
|
|
810
|
-
reason = reply.json&.fetch("reason", nil).to_s
|
|
811
|
-
"Detached; the session keeps running (#{held_words(reason, reply.json&.fetch("tasks", nil))}). " \
|
|
812
|
-
"Re-attach with: #{via.attach(id)}"
|
|
813
|
-
when 0 then exit_failed_line("no reply")
|
|
814
|
-
when 408 then exit_failed_line(LATE)
|
|
815
|
-
else
|
|
816
|
-
# A worker from before the route answers the Bridge's plain 404.
|
|
817
|
-
if error == "not_found"
|
|
818
|
-
return "Detached; this worker runs an older chi and can't be stopped from here: #{via.command("sessions stop #{id}")}"
|
|
819
|
-
end
|
|
820
|
-
|
|
821
|
-
exit_failed_line([reply.status, reply.json&.fetch("detail", nil) || error].compact.join(" "))
|
|
822
|
-
end
|
|
823
|
-
end
|
|
824
|
-
|
|
825
|
-
# Why the worker stayed up; the background tasks it waits for by
|
|
826
|
-
# their short id and command (a finish wake's, D3), and how to stop one.
|
|
827
|
-
def held_words(reason, tasks)
|
|
828
|
-
words = HELD_REASONS.fetch(reason, reason)
|
|
829
|
-
listed = Array(tasks).filter_map { |t| "#{Tools::TaskRecord.short_id(t["id"])} (#{t["command"]})" if t.is_a?(Hash) }
|
|
830
|
-
listed.empty? ? words : "#{words}: #{listed.join(", ")}; #{TASK_STOP_HINT}"
|
|
831
|
-
end
|
|
832
|
-
|
|
833
|
-
# An older worker leaves the field out.
|
|
834
|
-
def discards?(reply) = reply.json&.fetch("discard", nil) == true
|
|
835
|
-
|
|
836
|
-
def exit_failed_line(why)
|
|
837
|
-
"Detached (could not ask the worker to stop: #{why}). Re-attach with: #{via.attach(@client.session_id)}"
|
|
838
|
-
end
|
|
839
|
-
|
|
840
|
-
# The worker says what became of it (:continue_resolved); an invalid
|
|
841
|
-
# answer gets its error and the offer stays.
|
|
842
|
-
def answer_continue(text)
|
|
843
|
-
echo_answer(text) if TurnFlow.continue_decision(text).first == :invalid
|
|
844
|
-
@continue_answer = text.empty? ? "yes" : text
|
|
845
|
-
send_command(continue_line(text))
|
|
846
|
-
nil
|
|
847
|
-
end
|
|
848
|
-
|
|
849
|
-
# A bare answer at the continue prompt, as the REPL reads it (an empty
|
|
850
|
-
# one is yes).
|
|
851
|
-
def continue_line(text)
|
|
852
|
-
text.empty? ? SessionCommands::CONTINUE_COMMAND : "#{SessionCommands::CONTINUE_COMMAND} #{text}"
|
|
853
|
-
end
|
|
854
|
-
|
|
855
|
-
# The worker runs it and every UI renders its :command_ran, this one
|
|
856
|
-
# too, so nothing waits here.
|
|
857
|
-
def send_command(line)
|
|
858
|
-
reply = @client.post_command(line: line, client_id: client_id_for(line))
|
|
859
|
-
if reply.status == 202
|
|
860
|
-
# Input from a pipe ends once its output is in (#command_ran).
|
|
861
|
-
command_id = reply.json&.fetch("command_id", nil)
|
|
862
|
-
@open_ids << command_id if command_id && @wait_at_eof
|
|
863
|
-
return
|
|
864
|
-
end
|
|
865
|
-
return @screen.commit(stale_worker("run commands")) if reply.status == 404
|
|
866
|
-
|
|
867
|
-
name = older_worker_command(reply, line)
|
|
868
|
-
return @screen.commit(stale_worker("run #{name}")) if name
|
|
869
|
-
return @screen.commit("could not run the command (#{LATE})") if too_late?(reply)
|
|
870
|
-
|
|
871
|
-
detail = reply.json&.fetch("detail", nil) || reply.json&.fetch("error", nil)
|
|
872
|
-
@screen.commit("could not run the command (#{[reply.status, detail].compact.join(" ")})")
|
|
873
|
-
rescue SystemCallError, IOError => e
|
|
874
|
-
@screen.commit("could not run the command (#{worker_down(e)})")
|
|
875
|
-
end
|
|
876
|
-
|
|
877
458
|
# A request the Bridge read after its deadline and dropped
|
|
878
459
|
# (BridgeClient::DEADLINE_SHARE): it didn't go through.
|
|
879
460
|
def too_late?(reply) = reply.status == 408 && reply.json&.fetch("error", nil) == "deadline_passed"
|
|
@@ -890,316 +471,6 @@ module Samagotchi
|
|
|
890
471
|
"worker unreachable: #{head.downcase}"
|
|
891
472
|
end
|
|
892
473
|
|
|
893
|
-
QUEUED_NOTE = "(queued: runs after this turn)"
|
|
894
|
-
QUEUE_RUNS_NOW = "(the turn had ended: runs as the next turn)"
|
|
895
|
-
NOTHING_QUEUED = "(nothing queued: /queue TEXT queues a message for after the running turn)"
|
|
896
|
-
UNQUEUE_REFUSED = { "already_started" => "(not removed: it already started)",
|
|
897
|
-
"unknown_enqueued_id" => "(not removed: it is already gone)",
|
|
898
|
-
"not_queued" => "(not removed: it goes in at the next step)" }.freeze
|
|
899
|
-
|
|
900
|
-
# /queue to a worker from before queueing, which would steer it.
|
|
901
|
-
def queue_needs_newer_worker
|
|
902
|
-
"(/queue needs a newer worker: restart it with #{via.command("sessions restart")}, then send again)"
|
|
903
|
-
end
|
|
904
|
-
|
|
905
|
-
def unqueue_needs_newer_worker = "(/unqueue needs a newer worker: restart it with #{via.command("sessions restart")})"
|
|
906
|
-
|
|
907
|
-
# /unqueue: take back the newest prompt this run queued for after the
|
|
908
|
-
# turn, before it runs (POST turn/dequeue). Its :turn_dequeued comes
|
|
909
|
-
# to every UI; here the reply says what became of it. A worker whose
|
|
910
|
-
# sidecar lacks "unqueue" is not asked.
|
|
911
|
-
def unqueue_newest
|
|
912
|
-
return @screen.commit(unqueue_needs_newer_worker) unless worker_unqueues?
|
|
913
|
-
|
|
914
|
-
id = @my_queued.last
|
|
915
|
-
return @screen.commit(NOTHING_QUEUED) unless id
|
|
916
|
-
|
|
917
|
-
reply = @client.dequeue(enqueued_id: id, client_id: @client_id)
|
|
918
|
-
error = reply.json&.fetch("error", nil)
|
|
919
|
-
return @screen.commit(unqueue_needs_newer_worker) if reply.status == 404 && error == "not_found"
|
|
920
|
-
|
|
921
|
-
# Removed, started or gone: no longer this run's to take back.
|
|
922
|
-
@my_queued.delete(id) if [200, 404, 409].include?(reply.status)
|
|
923
|
-
return unqueued(reply.json&.fetch("prompt", nil).to_s) if reply.status == 200
|
|
924
|
-
|
|
925
|
-
@screen.commit(UNQUEUE_REFUSED[error] || "could not remove the queued message (#{[reply.status, error].compact.join(" ")})")
|
|
926
|
-
rescue SystemCallError, IOError => e
|
|
927
|
-
@screen.commit("could not remove the queued message (#{worker_down(e)})")
|
|
928
|
-
end
|
|
929
|
-
|
|
930
|
-
# The removal's line, and its text back in the input to change and
|
|
931
|
-
# send again (as a failed turn's prompt comes back, #restore_prompt):
|
|
932
|
-
# not over typed text, and not for lines from a pipe. Its images are
|
|
933
|
-
# not put back.
|
|
934
|
-
def unqueued(prompt)
|
|
935
|
-
@screen.commit("removed: #{first_line_clip(prompt)}")
|
|
936
|
-
return if @wait_at_eof || prompt.strip.empty?
|
|
937
|
-
|
|
938
|
-
# Set before the read restarts with it: the read's echo reads it.
|
|
939
|
-
@prefilled = prompt.strip
|
|
940
|
-
return if @reader&.prefill(prompt)
|
|
941
|
-
|
|
942
|
-
@prefilled = nil
|
|
943
|
-
@screen.commit("(the removed message is in the input history: ↑)")
|
|
944
|
-
end
|
|
945
|
-
|
|
946
|
-
def first_line_clip(text, max = 60)
|
|
947
|
-
line = text.to_s.strip.lines.first.to_s.strip
|
|
948
|
-
line.length > max ? "#{line[0, max]}…" : line
|
|
949
|
-
end
|
|
950
|
-
|
|
951
|
-
# Whether the attached worker takes queued messages back (its sidecar
|
|
952
|
-
# names SessionInbox::UNQUEUE_FEATURE). No sidecar to read: true, its Bridge
|
|
953
|
-
# answers (an older one's 404 not_found says so).
|
|
954
|
-
def worker_unqueues? = @link.unqueues?
|
|
955
|
-
|
|
956
|
-
# A command queued for after the running turn (command_queued with
|
|
957
|
-
# waits): its line now (another client's) and a note; its command_ran
|
|
958
|
-
# has no line again (queued: true).
|
|
959
|
-
def command_waits(event)
|
|
960
|
-
@queued_ids << event[:command_id]
|
|
961
|
-
if own?(event[:client_id])
|
|
962
|
-
first = event[:command_id] == @first_command_id
|
|
963
|
-
return @screen.commit(first ? first_command_queued : QUEUED_NOTE)
|
|
964
|
-
end
|
|
965
|
-
|
|
966
|
-
@screen.commit("#{prompt_line(event[:client_id], event[:line])} #{QUEUED_NOTE}")
|
|
967
|
-
end
|
|
968
|
-
|
|
969
|
-
def first_command_queued
|
|
970
|
-
"(#{first_command_flag} queued: applies after the running turn)"
|
|
971
|
-
end
|
|
972
|
-
|
|
973
|
-
def command_ran(event)
|
|
974
|
-
@open_ids.delete(event[:command_id])
|
|
975
|
-
@queued_ids.delete(event[:command_id])
|
|
976
|
-
unless own?(event[:client_id]) || event[:anytime] || event[:card] || event[:queued]
|
|
977
|
-
@screen.commit(prompt_line(event[:client_id], event[:line]))
|
|
978
|
-
end
|
|
979
|
-
output = event[:output].to_s
|
|
980
|
-
if output.empty?
|
|
981
|
-
nil
|
|
982
|
-
elsif %w[busy dropped].include?(event[:status]) || PromptHistory.shell_line?(event[:line])
|
|
983
|
-
@screen.commit(output)
|
|
984
|
-
put_back_busy_command(event) if event[:status] == "busy"
|
|
985
|
-
else
|
|
986
|
-
@screen.commit("#{paint("model>", 36)} #{output}")
|
|
987
|
-
end
|
|
988
|
-
prefill_confirm(event)
|
|
989
|
-
# /title from any client: the row's title follows.
|
|
990
|
-
@status.update(title: event[:title]) if Array(event[:changed]).include?("title")
|
|
991
|
-
return unless event[:model_name]
|
|
992
|
-
|
|
993
|
-
# After the output, so the status row changes with the line saying why.
|
|
994
|
-
served = @status[:model] == event[:model_name] ? @status[:served] : nil
|
|
995
|
-
@status.update(model: event[:model_name], default_model: default_model_name, served: served)
|
|
996
|
-
end
|
|
997
|
-
|
|
998
|
-
# Our change that asks first (CacheConfirm) puts its --yes line in the
|
|
999
|
-
# input: Enter applies it. Another client's question is its own.
|
|
1000
|
-
def prefill_confirm(event)
|
|
1001
|
-
return unless own?(event[:client_id])
|
|
1002
|
-
|
|
1003
|
-
confirm = event[:confirm]
|
|
1004
|
-
line = confirm.is_a?(Hash) ? confirm[:line] || confirm["line"] : nil
|
|
1005
|
-
@reader&.prefill(line.to_s) if line
|
|
1006
|
-
end
|
|
1007
|
-
|
|
1008
|
-
# Our command the worker couldn't run yet goes back into the prompt, for
|
|
1009
|
-
# Enter once the turn ends (not the launch's --model: that ends the launch).
|
|
1010
|
-
def put_back_busy_command(event)
|
|
1011
|
-
return unless own?(event[:client_id]) && event[:command_id] != @first_command_id
|
|
1012
|
-
|
|
1013
|
-
@screen.commit("(the command is in the input history: ↑)") unless @reader&.prefill(event[:line].to_s)
|
|
1014
|
-
end
|
|
1015
|
-
|
|
1016
|
-
def offer_continue(event)
|
|
1017
|
-
@continue_offer = { context: event[:context], no_interrupt: event[:no_interrupt] }
|
|
1018
|
-
sync_prompt
|
|
1019
|
-
sync_continue_slot
|
|
1020
|
-
end
|
|
1021
|
-
|
|
1022
|
-
CONTINUE_DECISIONS = { "resume" => "yes", "abort" => "no", "abort_with_reason" => "no, with a reason",
|
|
1023
|
-
"refused" => "not continued: the participant may no longer run turns" }.freeze
|
|
1024
|
-
|
|
1025
|
-
# The offer's choices go; one line says what became of it. Another
|
|
1026
|
-
# UI's answer shows as its command line (web> /continue yes) instead.
|
|
1027
|
-
def continue_resolved(event)
|
|
1028
|
-
@continue_offer = nil
|
|
1029
|
-
# Asked as the step-limit question: its line says what became of it.
|
|
1030
|
-
asked = @offer_asked
|
|
1031
|
-
@offer_asked = false
|
|
1032
|
-
sync_continue_slot
|
|
1033
|
-
who = client_label(event[:client_id], "another UI")
|
|
1034
|
-
outcome = if event[:decision] == "dropped"
|
|
1035
|
-
"(dropped: #{own?(event[:client_id]) ? "you" : who} sent a new prompt)"
|
|
1036
|
-
elsif event[:decision] == "refused"
|
|
1037
|
-
CONTINUE_DECISIONS["refused"]
|
|
1038
|
-
elsif own?(event[:client_id])
|
|
1039
|
-
@continue_answer || CONTINUE_DECISIONS.fetch(event[:decision].to_s, event[:decision].to_s)
|
|
1040
|
-
end
|
|
1041
|
-
@continue_answer = nil
|
|
1042
|
-
@screen.commit(QuestionSlot.continue_summary(outcome, paint: method(:paint))) if outcome && !asked
|
|
1043
|
-
# Back to the > prompt: with the text a question set aside meanwhile
|
|
1044
|
-
# (#close_question), else what the ? read holds.
|
|
1045
|
-
set_aside = @set_aside
|
|
1046
|
-
@set_aside = nil
|
|
1047
|
-
set_aside ? sync_prompt(keep_text: false, prefill: set_aside) : sync_prompt
|
|
1048
|
-
end
|
|
1049
|
-
|
|
1050
|
-
# The notes slot shows the offer's choices while it waits (a question
|
|
1051
|
-
# has the slot while it is open).
|
|
1052
|
-
def sync_continue_slot
|
|
1053
|
-
return if @question || @offer_asked
|
|
1054
|
-
|
|
1055
|
-
if @continue_offer
|
|
1056
|
-
@screen.set_slot(:notes, QuestionSlot.continue_offer(@continue_offer[:context], paint: method(:paint)))
|
|
1057
|
-
else
|
|
1058
|
-
@screen.clear_slot(:notes)
|
|
1059
|
-
end
|
|
1060
|
-
end
|
|
1061
|
-
|
|
1062
|
-
# What a prompt's 202 says became of it, or nil when there is nothing
|
|
1063
|
-
# to say: the dim line both TUIs print for a delivery. A plain line
|
|
1064
|
-
# for a running turn goes in at its next step; a cut's outcome comes
|
|
1065
|
-
# from the ack (the worker knows whether cutting is on for its model);
|
|
1066
|
-
# a queued one waits for the turn's end (the ack says queue).
|
|
1067
|
-
# An idle session's prompt simply runs, and a prompt with images runs
|
|
1068
|
-
# as its own next turn (never merging mid-turn), so nothing is said;
|
|
1069
|
-
# one typed as /cut or /queue says it was sent now (+choice+).
|
|
1070
|
-
# @param ack [Hash, nil] the Bridge's 202 body
|
|
1071
|
-
# @param delivery [String, nil] what was asked for
|
|
1072
|
-
# @return [String, nil]
|
|
1073
|
-
def sent_note(ack, delivery: nil, images: [], running: false, choice: nil)
|
|
1074
|
-
return STEER_CHOICE_IDLE if choice && !running
|
|
1075
|
-
return nil unless running && ack.is_a?(Hash)
|
|
1076
|
-
# The Bridge acks queue only while a turn ran at enqueue; a /queue
|
|
1077
|
-
# it acks next_step found the turn over: it is the next turn.
|
|
1078
|
-
return QUEUED_NOTE if ack["delivery"] == Delivery::QUEUE
|
|
1079
|
-
return QUEUE_RUNS_NOW if Delivery.queue?(delivery)
|
|
1080
|
-
return nil unless images.empty?
|
|
1081
|
-
|
|
1082
|
-
return NEXT_STEP_NOTE unless delivery == Delivery::CUT
|
|
1083
|
-
|
|
1084
|
-
steer_cut_note(ack["cut"].to_s.to_sym, ack["cut_after"])
|
|
1085
|
-
end
|
|
1086
|
-
|
|
1087
|
-
# The client id +line+ goes with, as a command (#send_command) or as a
|
|
1088
|
-
# message the Bridge may run as one (#send_prompt: /cut and /queue
|
|
1089
|
-
# text). A parent agent's /continue yes and /parent-answers go as chi
|
|
1090
|
-
# answer's: the worker holds the first to guardrails.parent_answers and
|
|
1091
|
-
# never takes the second as a user's loosening. The command is the
|
|
1092
|
-
# registry's own match (the worker's), not a pattern of ours.
|
|
1093
|
-
def client_id_for(line)
|
|
1094
|
-
return @client_id unless @as_parent
|
|
1095
|
-
return ClientId::CLI_ANSWER if TurnFlow.continue_yes?(line)
|
|
1096
|
-
return ClientId::CLI_ANSWER if command_registry.lookup(line)&.id == :parent_answers
|
|
1097
|
-
|
|
1098
|
-
@client_id
|
|
1099
|
-
end
|
|
1100
|
-
|
|
1101
|
-
# @param choice [Symbol, nil] :cut or :queue when typed with that word
|
|
1102
|
-
def send_prompt(text, delivery: nil, choice: nil)
|
|
1103
|
-
persist_recent_history(text)
|
|
1104
|
-
images = attach_images(text)
|
|
1105
|
-
return own_prompt_failed if images.nil?
|
|
1106
|
-
|
|
1107
|
-
running = @running
|
|
1108
|
-
# Off a live region (no echo seam) the divider comes here; after
|
|
1109
|
-
# #gap_above_echo it marks none.
|
|
1110
|
-
mark_activity
|
|
1111
|
-
options = { prompt: text, client_id: client_id_for(text) }
|
|
1112
|
-
options[:no_interrupt] = true if @no_interrupt
|
|
1113
|
-
options[:images] = images unless images.empty?
|
|
1114
|
-
options[:delivery] = delivery if delivery
|
|
1115
|
-
reply = @client.post_turn(**options)
|
|
1116
|
-
if reply.status == 202
|
|
1117
|
-
enqueued_id = reply.json&.fetch("enqueued_id", nil)
|
|
1118
|
-
@sent_ids << enqueued_id if enqueued_id
|
|
1119
|
-
@open_ids << enqueued_id if enqueued_id && @wait_at_eof
|
|
1120
|
-
@my_queued << enqueued_id if enqueued_id && reply.json&.fetch("delivery", nil) == Delivery::QUEUE
|
|
1121
|
-
note = sent_note(reply.json, delivery: delivery, images: images, running: running, choice: choice)
|
|
1122
|
-
@screen.commit(note) if note
|
|
1123
|
-
return
|
|
1124
|
-
end
|
|
1125
|
-
|
|
1126
|
-
own_prompt_failed
|
|
1127
|
-
|
|
1128
|
-
detail = reply.json&.fetch("error", nil)
|
|
1129
|
-
# Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
|
|
1130
|
-
return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
|
|
1131
|
-
# A worker from 0.51 to 0.63 refuses a queue (nothing was sent).
|
|
1132
|
-
return @screen.commit(queue_needs_newer_worker) if detail == "delivery_unavailable"
|
|
1133
|
-
|
|
1134
|
-
@screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})")
|
|
1135
|
-
rescue SystemCallError, IOError => e
|
|
1136
|
-
own_prompt_failed
|
|
1137
|
-
@screen.commit("could not send the prompt (#{worker_down(e)})")
|
|
1138
|
-
end
|
|
1139
|
-
|
|
1140
|
-
# Whether the input's end must wait: the -p prompt isn't sent yet, a
|
|
1141
|
-
# prompt this run sent hasn't had its turn, or its turn's after_turn
|
|
1142
|
-
# hooks still run (their notices, source-links' sources:, come next).
|
|
1143
|
-
def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty? || @display_pending == true
|
|
1144
|
-
|
|
1145
|
-
# A turn ended: this run's prompt (and ours merged into it) had its turn.
|
|
1146
|
-
def own_turn_ended(event)
|
|
1147
|
-
return unless @wait_at_eof
|
|
1148
|
-
|
|
1149
|
-
id = origin_of(event[:origin]).enqueued_id
|
|
1150
|
-
ours = @open_ids.include?(id) || !@merged_ids.empty?
|
|
1151
|
-
@own_failed = true if ours && event[:type] == :turn_failed
|
|
1152
|
-
@own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
|
|
1153
|
-
# The worker sends answer_display once its after_turn hooks ran.
|
|
1154
|
-
@display_pending = true if ours && event[:type] == :turn_completed && event[:display_pending]
|
|
1155
|
-
@open_ids.delete(id)
|
|
1156
|
-
@open_ids.subtract(@merged_ids)
|
|
1157
|
-
@merged_ids.clear
|
|
1158
|
-
end
|
|
1159
|
-
|
|
1160
|
-
# Ours merged into the running turn: they end with it.
|
|
1161
|
-
def merged_own_prompts(event)
|
|
1162
|
-
Array(event[:origins]).each do |value|
|
|
1163
|
-
origin = origin_of(value)
|
|
1164
|
-
id = origin.enqueued_id
|
|
1165
|
-
@merged_ids << id if own?(origin.client_id) && @open_ids.include?(id)
|
|
1166
|
-
end
|
|
1167
|
-
nil
|
|
1168
|
-
end
|
|
1169
|
-
|
|
1170
|
-
def own_prompt_failed
|
|
1171
|
-
@own_failed = true if @wait_at_eof
|
|
1172
|
-
nil
|
|
1173
|
-
end
|
|
1174
|
-
|
|
1175
|
-
# The input ended and nothing of ours is left to wait for.
|
|
1176
|
-
def end_of_input
|
|
1177
|
-
detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if @input_ended
|
|
1178
|
-
return :turn_failed if @own_failed
|
|
1179
|
-
|
|
1180
|
-
@own_empty ? :empty_answer : :detached
|
|
1181
|
-
end
|
|
1182
|
-
|
|
1183
|
-
def unanswered_question(pending)
|
|
1184
|
-
@unanswered = pending
|
|
1185
|
-
detach("A question waits for an answer: #{via.attach(@client.session_id)}")
|
|
1186
|
-
:unanswered
|
|
1187
|
-
end
|
|
1188
|
-
|
|
1189
|
-
# The prompt's `@path` images, stored where the worker reads them
|
|
1190
|
-
# (AttachLink#ingest_images: the session's images/ here) and sent as
|
|
1191
|
-
# refs: the Bridge never takes a path. nil (after a line saying why)
|
|
1192
|
-
# when one can't be.
|
|
1193
|
-
def attach_images(text)
|
|
1194
|
-
paths = ImageInput.extract(text)
|
|
1195
|
-
return [] if paths.empty?
|
|
1196
|
-
|
|
1197
|
-
@link.ingest_images(paths.map { |image| image[:path] })
|
|
1198
|
-
rescue ImageStore::Error => e
|
|
1199
|
-
@screen.commit("could not attach the image: #{e.message}")
|
|
1200
|
-
nil
|
|
1201
|
-
end
|
|
1202
|
-
|
|
1203
474
|
# /share and /participants: chi share for this session, where it is.
|
|
1204
475
|
def participant_commands
|
|
1205
476
|
@participant_commands ||= ParticipantCommands.new(access: @link.session_access, session_id: @client.session_id,
|
|
@@ -1214,36 +485,6 @@ module Samagotchi
|
|
|
1214
485
|
@screen.commit("(no metrics: #{via.remote? ? e.message : "the worker did not answer"})")
|
|
1215
486
|
end
|
|
1216
487
|
|
|
1217
|
-
# One read on the reader thread: the REPL's multiline read with Tab
|
|
1218
|
-
# completion at the main prompt, a plain line for a question's choice
|
|
1219
|
-
# or the continue offer's answer.
|
|
1220
|
-
def read_input_line(prompt, prefill)
|
|
1221
|
-
return $stdin.gets&.chomp unless $stdin.tty?
|
|
1222
|
-
# A question's answer leaves only its summary line.
|
|
1223
|
-
return RelineSeam.without_echo { Reline.readline(prompt, true) } if @question || @continue_offer
|
|
1224
|
-
|
|
1225
|
-
queue_input_prefill(prefill) if prefill
|
|
1226
|
-
read_prompt_line(prompt)
|
|
1227
|
-
end
|
|
1228
|
-
|
|
1229
|
-
# The attached TUI's commands, for Tab.
|
|
1230
|
-
def slash_commands = command_registry.completions(:attached)
|
|
1231
|
-
|
|
1232
|
-
# @path and #memory candidates, as the link offers them
|
|
1233
|
-
# (AttachLink#completions).
|
|
1234
|
-
def build_project_path_completion_candidates(path_fragment)
|
|
1235
|
-
@link.completions.paths ? super : []
|
|
1236
|
-
end
|
|
1237
|
-
|
|
1238
|
-
def build_memory_completion_candidates(memory_fragment)
|
|
1239
|
-
@link.completions.memories ? super : []
|
|
1240
|
-
end
|
|
1241
|
-
|
|
1242
|
-
# The session's commands as its worker's snapshot names them (its
|
|
1243
|
-
# plugins' too); the built-ins until one arrives, or from a worker too
|
|
1244
|
-
# old to name them. An unknown /foo is a prompt, as in the REPL.
|
|
1245
|
-
def command_registry = @command_registry || SessionCommands.builtin_registry
|
|
1246
|
-
|
|
1247
488
|
# The saved recap, and a new one asked for at once (it arrives as
|
|
1248
489
|
# :recap_ready), with the REPL's words.
|
|
1249
490
|
def show_recap
|
|
@@ -1258,95 +499,6 @@ module Samagotchi
|
|
|
1258
499
|
@screen.commit("(no recap: #{via.remote? ? e.message : "the worker did not answer"})")
|
|
1259
500
|
end
|
|
1260
501
|
|
|
1261
|
-
def prompt_text
|
|
1262
|
-
return paint(QUESTION_PROMPT, 33) if @question || @continue_offer
|
|
1263
|
-
|
|
1264
|
-
paint(PROMPT, 92)
|
|
1265
|
-
end
|
|
1266
|
-
|
|
1267
|
-
# Switch the open prompt to ? for the answer and show the choices in
|
|
1268
|
-
# the notes slot, fitted to the terminal. Once the question closes,
|
|
1269
|
-
# one line (the question and what became of it) stays.
|
|
1270
|
-
def ask(pending)
|
|
1271
|
-
return unless pending
|
|
1272
|
-
|
|
1273
|
-
@view.finish_thinking_spinner
|
|
1274
|
-
# What was typed at the prompt waits for the question to close.
|
|
1275
|
-
# Not over text already set aside (a question that closed while a
|
|
1276
|
-
# continue offer waits): the ? read holds an answer then, if anything.
|
|
1277
|
-
@set_aside = @reader&.typed_text.to_s.then { |typed| typed.empty? ? nil : typed } unless @question || @set_aside
|
|
1278
|
-
@question = QuestionPrompt.new(pending)
|
|
1279
|
-
@question_pending = pending
|
|
1280
|
-
# The step-limit question stands for the continue offer's own slot
|
|
1281
|
-
# until the offer is resolved.
|
|
1282
|
-
@offer_asked = true if @question.continue?
|
|
1283
|
-
# An edit's diff goes above, into the scrollback, once per question
|
|
1284
|
-
# (a join gets it from the snapshot and may get the event too).
|
|
1285
|
-
unless @previewed_id == @question.id
|
|
1286
|
-
@previewed_id = @question.id
|
|
1287
|
-
preview = @question.preview_lines(paint: method(:paint))
|
|
1288
|
-
@screen.commit(preview.join("\n")) unless preview.empty?
|
|
1289
|
-
end
|
|
1290
|
-
# The prompt first, so the choices never show under the typed text.
|
|
1291
|
-
sync_prompt(keep_text: false)
|
|
1292
|
-
@screen.set_slot(:notes, @question.slot(paint: method(:paint)))
|
|
1293
|
-
end
|
|
1294
|
-
|
|
1295
|
-
def answer_question(text)
|
|
1296
|
-
# Enter alone at the step-limit question continues (it can't be
|
|
1297
|
-
# dismissed), as at the continue prompt.
|
|
1298
|
-
return dismiss_question if text.empty? && !@question.continue?
|
|
1299
|
-
|
|
1300
|
-
answer = @question.parse(text)
|
|
1301
|
-
@screen.commit(answer.note) if answer.note
|
|
1302
|
-
unless answer.ok?
|
|
1303
|
-
echo_answer(text)
|
|
1304
|
-
return @screen.commit(answer.error)
|
|
1305
|
-
end
|
|
1306
|
-
|
|
1307
|
-
parent = @as_parent ? { client_id: ClientId::CLI_ANSWER } : {}
|
|
1308
|
-
reply = @client.answer(id: @question.id, selected: answer.selected, freeform: answer.freeform, **parent)
|
|
1309
|
-
case reply.status
|
|
1310
|
-
when 200
|
|
1311
|
-
@answered_ids << @question.id
|
|
1312
|
-
close_question(@question.answer_text(answer))
|
|
1313
|
-
when 409 then close_question("(already answered in another UI)")
|
|
1314
|
-
when 408 then @screen.commit("could not answer (#{LATE})")
|
|
1315
|
-
else
|
|
1316
|
-
detail = reply.json&.fetch("detail", nil) || reply.json&.fetch("error", nil)
|
|
1317
|
-
@screen.commit("could not answer (#{[reply.status, detail].compact.join(" ")})")
|
|
1318
|
-
end
|
|
1319
|
-
nil
|
|
1320
|
-
rescue SystemCallError, IOError => e
|
|
1321
|
-
@screen.commit("could not answer (#{worker_down(e)})")
|
|
1322
|
-
nil
|
|
1323
|
-
end
|
|
1324
|
-
|
|
1325
|
-
# An empty answer dismisses the question, as in the REPL: the tool
|
|
1326
|
-
# returns unanswered (an approval: denied) and the turn goes on. The
|
|
1327
|
-
# :question_cancelled every UI gets finds it already closed here.
|
|
1328
|
-
def dismiss_question
|
|
1329
|
-
reply = @client.dismiss_question(id: @question.id)
|
|
1330
|
-
case reply.status
|
|
1331
|
-
when 200 then close_question(@question.approval? ? "(denied)" : "(cancelled)")
|
|
1332
|
-
when 409 then close_question("(question already closed in another UI)")
|
|
1333
|
-
when 404 then @screen.commit("#{stale_worker("dismiss questions")}; Ctrl-C cancels the turn")
|
|
1334
|
-
when 408 then @screen.commit("could not dismiss the question (#{LATE}); Ctrl-C cancels the turn")
|
|
1335
|
-
else
|
|
1336
|
-
# The question stays open (after a 404 too).
|
|
1337
|
-
detail = reply.json&.fetch("error", nil)
|
|
1338
|
-
@screen.commit("could not dismiss the question (#{[reply.status, detail].compact.join(" ")}); Ctrl-C cancels the turn")
|
|
1339
|
-
end
|
|
1340
|
-
nil
|
|
1341
|
-
rescue SystemCallError, IOError => e
|
|
1342
|
-
@screen.commit("could not dismiss the question (#{worker_down(e)}); Ctrl-C cancels the turn")
|
|
1343
|
-
nil
|
|
1344
|
-
end
|
|
1345
|
-
|
|
1346
|
-
# A line read at ? that isn't a (valid) answer: the read left no echo,
|
|
1347
|
-
# so it shows above what it got.
|
|
1348
|
-
def echo_answer(text) = @screen.commit("#{paint(QUESTION_PROMPT, 33)}#{text}")
|
|
1349
|
-
|
|
1350
502
|
# The name of this chi's own command +line+ when the worker answered it
|
|
1351
503
|
# unknown_command (it runs an older chi without it), else nil.
|
|
1352
504
|
def older_worker_command(reply, line)
|
|
@@ -1374,100 +526,6 @@ module Samagotchi
|
|
|
1374
526
|
end
|
|
1375
527
|
end
|
|
1376
528
|
|
|
1377
|
-
def question_relayed(event)
|
|
1378
|
-
return unless @question && @question.id == event[:id].to_s
|
|
1379
|
-
|
|
1380
|
-
@question.relayed_to = event[:relayed_to]
|
|
1381
|
-
@screen.set_slot(:notes, @question.slot(paint: method(:paint)))
|
|
1382
|
-
end
|
|
1383
|
-
|
|
1384
|
-
def question_answered(event)
|
|
1385
|
-
return unless @question
|
|
1386
|
-
return close_question(nil) if @answered_ids.include?(event[:id])
|
|
1387
|
-
|
|
1388
|
-
answer = event[:answer] || {}
|
|
1389
|
-
picked = [*Array(answer[:selected]), answer[:freeform]].compact.join(", ")
|
|
1390
|
-
close_question("#{picked.empty? ? "(answered)" : picked} (in another UI)")
|
|
1391
|
-
end
|
|
1392
|
-
|
|
1393
|
-
# @param outcome [String, nil] what became of it, for the line that
|
|
1394
|
-
# stays in the scrollback (nil: none, it was closed already)
|
|
1395
|
-
def close_question(outcome)
|
|
1396
|
-
@screen.clear_slot(:notes)
|
|
1397
|
-
@screen.commit(@question.summary(outcome, paint: method(:paint))) if outcome
|
|
1398
|
-
@question = nil
|
|
1399
|
-
# A continue offer still waiting keeps the ? prompt (the step-limit
|
|
1400
|
-
# question closes before its offer resolves): the text set aside
|
|
1401
|
-
# waits for the > prompt (#continue_resolved).
|
|
1402
|
-
return sync_continue_slot if @continue_offer
|
|
1403
|
-
|
|
1404
|
-
set_aside = @set_aside
|
|
1405
|
-
@set_aside = nil
|
|
1406
|
-
sync_prompt(keep_text: false, prefill: set_aside)
|
|
1407
|
-
end
|
|
1408
|
-
|
|
1409
|
-
# Restart the open read when its prompt no longer fits (a question or a
|
|
1410
|
-
# continue offer opened or closed), first erasing the prompt Reline drew.
|
|
1411
|
-
# What is typed there goes along (+keep_text+), or +prefill+ goes in.
|
|
1412
|
-
def sync_prompt(keep_text: true, prefill: nil)
|
|
1413
|
-
return unless @reader
|
|
1414
|
-
|
|
1415
|
-
@screen.synchronize do
|
|
1416
|
-
next if @reader.current == prompt_text
|
|
1417
|
-
|
|
1418
|
-
@screen.clear_slot(:editor)
|
|
1419
|
-
@reader.reprompt(keep_text: keep_text, prefill: prefill)
|
|
1420
|
-
end
|
|
1421
|
-
end
|
|
1422
|
-
|
|
1423
|
-
# Ctrl-C cancels the running turn (whoever started it). At an idle
|
|
1424
|
-
# prompt it clears the line; on an empty one it says how to detach, and
|
|
1425
|
-
# a second press within DETACH_WINDOW detaches (D4).
|
|
1426
|
-
# @param pressed [Hash, nil] {text:, at:} from the read the press ended
|
|
1427
|
-
# (nil: pressed with no read open)
|
|
1428
|
-
# @return [Symbol, nil] :detach
|
|
1429
|
-
def interrupt(pressed = nil)
|
|
1430
|
-
typed = pressed&.fetch(:text, nil).to_s
|
|
1431
|
-
if @running
|
|
1432
|
-
begin
|
|
1433
|
-
@client.cancel(reason: "ctrl_c")
|
|
1434
|
-
rescue SystemCallError, IOError => e
|
|
1435
|
-
@screen.commit("could not cancel the turn (#{worker_down(e)})")
|
|
1436
|
-
end
|
|
1437
|
-
return nil
|
|
1438
|
-
end
|
|
1439
|
-
unless typed.strip.empty?
|
|
1440
|
-
@last_idle_interrupt_at = nil
|
|
1441
|
-
return nil
|
|
1442
|
-
end
|
|
1443
|
-
|
|
1444
|
-
now = pressed&.fetch(:at, nil) || @clock.call
|
|
1445
|
-
return submit(nil) if @last_idle_interrupt_at && now - @last_idle_interrupt_at <= DETACH_WINDOW
|
|
1446
|
-
|
|
1447
|
-
@last_idle_interrupt_at = now
|
|
1448
|
-
@screen.commit(DETACH_HINT)
|
|
1449
|
-
nil
|
|
1450
|
-
end
|
|
1451
|
-
|
|
1452
|
-
# On the reader thread, from Reline's Ctrl-C: what the line held, and
|
|
1453
|
-
# when. During a turn the press goes to the loop (it cancels the turn)
|
|
1454
|
-
# and the read goes on with the typed text still in it, as in the REPL;
|
|
1455
|
-
# otherwise it is noted for the Interrupt the LineReader queues next.
|
|
1456
|
-
# @return [Boolean] true: handled, the read goes on; false: Reline ends
|
|
1457
|
-
# the read with Interrupt
|
|
1458
|
-
def note_interrupted_line
|
|
1459
|
-
pressed = { text: Reline.line_buffer.to_s, at: @clock.call }
|
|
1460
|
-
if @running
|
|
1461
|
-
@queue << [:interrupt, pressed]
|
|
1462
|
-
return true
|
|
1463
|
-
end
|
|
1464
|
-
|
|
1465
|
-
Thread.current[:interrupted_line] = pressed
|
|
1466
|
-
false
|
|
1467
|
-
rescue StandardError
|
|
1468
|
-
false
|
|
1469
|
-
end
|
|
1470
|
-
|
|
1471
529
|
def render_snapshot(snapshot, reset:)
|
|
1472
530
|
@view.finish_thinking_spinner
|
|
1473
531
|
queued = Array(snapshot[:queued])
|
|
@@ -1488,8 +546,7 @@ module Samagotchi
|
|
|
1488
546
|
# The recap first: what the session was about, then where it stopped.
|
|
1489
547
|
saved = snapshot[:saved_recap]
|
|
1490
548
|
@screen.commit(recap_block(saved[:text], turns_since: saved[:turns_since], at: saved[:created_at])) if saved && !saved[:text].to_s.strip.empty?
|
|
1491
|
-
|
|
1492
|
-
render_join_gap(snapshot, saved)
|
|
549
|
+
@join_replay.render_join(snapshot, saved)
|
|
1493
550
|
@screen.commit("guardrails> #{snapshot[:guardrail_warning]}") if snapshot[:guardrail_warning]
|
|
1494
551
|
@screen.commit("plugins> #{snapshot[:plugin_warning]}") if snapshot[:plugin_warning]
|
|
1495
552
|
version_line = VersionLines.at_attach(worker: snapshot[:chi_version], installed: installed_version,
|
|
@@ -1501,7 +558,7 @@ module Samagotchi
|
|
|
1501
558
|
end
|
|
1502
559
|
Array(snapshot[:init_tasks]).each { |task| @view.init_started(task) }
|
|
1503
560
|
cards = Array(snapshot[:cards])
|
|
1504
|
-
render_snapshot_cards(cards.reject { |card| card[:current] }, joining: !reset)
|
|
561
|
+
@join_replay.render_snapshot_cards(cards.reject { |card| card[:current] }, joining: !reset)
|
|
1505
562
|
offer = snapshot[:continue_offer]
|
|
1506
563
|
had_offer = !@continue_offer.nil?
|
|
1507
564
|
@continue_offer = offer && { context: offer[:context], no_interrupt: offer[:no_interrupt] }
|
|
@@ -1512,7 +569,7 @@ module Samagotchi
|
|
|
1512
569
|
render_current_turn(snapshot[:current_turn])
|
|
1513
570
|
# A question between turns (the step-limit one) belongs to no turn.
|
|
1514
571
|
ask(snapshot[:pending_question]) if snapshot[:current_turn].nil? && snapshot[:pending_question]
|
|
1515
|
-
render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
|
|
572
|
+
@join_replay.render_snapshot_cards(cards.select { |card| card[:current] }, joining: !reset)
|
|
1516
573
|
queued.each do |entry|
|
|
1517
574
|
next if own?(entry[:client_id])
|
|
1518
575
|
|
|
@@ -1533,270 +590,6 @@ module Samagotchi
|
|
|
1533
590
|
nil
|
|
1534
591
|
end
|
|
1535
592
|
|
|
1536
|
-
# --model (or --llm-context, --thinking) on a resumed session: run it in its
|
|
1537
|
-
# worker before the first prompt goes; its :command_ran decides
|
|
1538
|
-
# (#first_command_ran), and sends the next one.
|
|
1539
|
-
# @return [Symbol, nil] :failed when the worker didn't take it
|
|
1540
|
-
def send_first_command
|
|
1541
|
-
line = @first_commands.shift
|
|
1542
|
-
@first_command_line = line
|
|
1543
|
-
reply = @client.post_command(line: line, client_id: @client_id)
|
|
1544
|
-
if reply.status == 202
|
|
1545
|
-
@first_command_id = reply.json&.fetch("command_id", nil)
|
|
1546
|
-
return nil if @first_command_id
|
|
1547
|
-
end
|
|
1548
|
-
|
|
1549
|
-
why =
|
|
1550
|
-
if reply.status == 404 then stale_worker("run commands")
|
|
1551
|
-
elsif (name = older_worker_command(reply, line)) then stale_worker("run #{name}")
|
|
1552
|
-
elsif too_late?(reply) then LATE
|
|
1553
|
-
else "the worker answered #{reply.status}"
|
|
1554
|
-
end
|
|
1555
|
-
@screen.commit("#{first_command_failed}: #{why}")
|
|
1556
|
-
:failed
|
|
1557
|
-
rescue SystemCallError, IOError => e
|
|
1558
|
-
@screen.commit("#{first_command_failed}: #{worker_down(e)}")
|
|
1559
|
-
:failed
|
|
1560
|
-
end
|
|
1561
|
-
|
|
1562
|
-
# @return [Symbol, nil] :failed unless the command went through
|
|
1563
|
-
def first_command_ran(event)
|
|
1564
|
-
@first_command_id = nil
|
|
1565
|
-
if event[:status] == "ok"
|
|
1566
|
-
return send_first_command unless @first_commands.empty?
|
|
1567
|
-
|
|
1568
|
-
send_first_prompt
|
|
1569
|
-
return nil
|
|
1570
|
-
end
|
|
1571
|
-
|
|
1572
|
-
@screen.commit("#{first_command_failed}: #{event[:output]}")
|
|
1573
|
-
:failed
|
|
1574
|
-
end
|
|
1575
|
-
|
|
1576
|
-
def first_command_failed
|
|
1577
|
-
flag = first_command_flag
|
|
1578
|
-
flag == "--model" ? "could not switch to the --model" : "could not set the #{flag}"
|
|
1579
|
-
end
|
|
1580
|
-
|
|
1581
|
-
# The launch flag the first command came from: "/model X" is --model,
|
|
1582
|
-
# "/llm-context …" --llm-context, "/thinking X --yes" --thinking.
|
|
1583
|
-
def first_command_flag
|
|
1584
|
-
"--#{@first_command_line.to_s[%r{\A/([\w-]+)}, 1]}"
|
|
1585
|
-
end
|
|
1586
|
-
|
|
1587
|
-
# After the join, so it lands below what the session already had. Our
|
|
1588
|
-
# own prompts are never echoed from events (a typed one stays on screen),
|
|
1589
|
-
# so it is shown here as if typed.
|
|
1590
|
-
def send_first_prompt
|
|
1591
|
-
text = @first_prompt.to_s.strip
|
|
1592
|
-
@first_prompt = nil
|
|
1593
|
-
return if text.empty?
|
|
1594
|
-
|
|
1595
|
-
@screen.commit("#{paint(PROMPT, 92)}#{text}")
|
|
1596
|
-
# As if typed: a session command runs as the command; a typo of one
|
|
1597
|
-
# gets the hint, not a turn.
|
|
1598
|
-
if command_registry.command?(text)
|
|
1599
|
-
send_command(text)
|
|
1600
|
-
elsif (hint = command_registry.unknown_command_hint(text))
|
|
1601
|
-
@screen.commit(hint)
|
|
1602
|
-
else
|
|
1603
|
-
send_prompt(text)
|
|
1604
|
-
end
|
|
1605
|
-
end
|
|
1606
|
-
|
|
1607
|
-
# Where the session's last activity was: its newest stamped entry,
|
|
1608
|
-
# else its saved recap's time. A gap ago (and no turn running): one
|
|
1609
|
-
# line after the join block says when.
|
|
1610
|
-
def render_join_gap(snapshot, saved)
|
|
1611
|
-
@gaps.seed(snapshot[:messages], fallback: saved&.dig(:created_at))
|
|
1612
|
-
return if snapshot[:current_turn]
|
|
1613
|
-
|
|
1614
|
-
line = @gaps.join_line
|
|
1615
|
-
@screen.commit(gap_divider(line)) if line
|
|
1616
|
-
end
|
|
1617
|
-
|
|
1618
|
-
def render_join_header(messages)
|
|
1619
|
-
exchange = messages.select { |m| %w[user model assistant].include?(m[:role].to_s) }
|
|
1620
|
-
# The id stays findable: the detach line and chi sessions list show it.
|
|
1621
|
-
Log.session_id = @client.session_id
|
|
1622
|
-
Log.info(:attached, "joined", session: @client.session_id, messages: exchange.size,
|
|
1623
|
-
notes: messages.count { |m| ContextNote.note?(m) })
|
|
1624
|
-
last_user = exchange.rindex { |m| Steer.prompt?(m) }
|
|
1625
|
-
render_join_notes(messages)
|
|
1626
|
-
return unless last_user
|
|
1627
|
-
|
|
1628
|
-
@screen.commit(join_prompt_line(exchange[last_user]))
|
|
1629
|
-
# An image-only turn's text names its images already.
|
|
1630
|
-
unless ImageRef.placeholder_only?(exchange[last_user][:content])
|
|
1631
|
-
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
1632
|
-
end
|
|
1633
|
-
from = messages.index { |m| m.equal?(exchange[last_user]) }
|
|
1634
|
-
render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
|
|
1635
|
-
record = @link.saved_turn_record(exchange[last_user][:turn_id] || exchange[last_user]["turn_id"])
|
|
1636
|
-
failed = join_failed_record(record)
|
|
1637
|
-
# A turn that ended with no answer: its notice, as live.
|
|
1638
|
-
empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
|
|
1639
|
-
if empty
|
|
1640
|
-
@screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"], empty[:refusal] || empty["refusal"]))
|
|
1641
|
-
elsif failed
|
|
1642
|
-
# A failed turn whose steps stayed: the line the live view left,
|
|
1643
|
-
# and no step text as if it were the answer (the web keeps a
|
|
1644
|
-
# failed turn's texts as steps too).
|
|
1645
|
-
failure = failed["failure"]
|
|
1646
|
-
@screen.commit(@view.turn_failed_line(failure["summary"] || failure["message"], failed["duration_ms"]))
|
|
1647
|
-
@screen.commit(@view.turn_end_hint(ROLLBACK_HINT)) if failure["kept_steps"]
|
|
1648
|
-
elsif record && record["status"] == "canceled"
|
|
1649
|
-
# A canceled turn (a Stop, Ctrl-C): its line, as live, and no
|
|
1650
|
-
# text it streamed before the cancel as if it were the answer.
|
|
1651
|
-
@screen.commit(@view.turn_canceled_line(record["cancellation_reason"], record["duration_ms"], by: record["cancelled_by"]))
|
|
1652
|
-
@screen.commit(@view.turn_end_hint(ROLLBACK_HINT)) unless record["continue"]
|
|
1653
|
-
else
|
|
1654
|
-
# The saved answer is raw: the latest keeps its thinking, and one may
|
|
1655
|
-
# be only a tool call.
|
|
1656
|
-
answer = exchange[(last_user + 1)..].reverse_each
|
|
1657
|
-
.map { |m| OutputFormatter.strip_markup(m[:content]) }
|
|
1658
|
-
.find { |text| !text.empty? }
|
|
1659
|
-
@screen.commit(last_lines(answer)) if answer
|
|
1660
|
-
end
|
|
1661
|
-
render_join_notes(messages, after_exchange: true)
|
|
1662
|
-
end
|
|
1663
|
-
|
|
1664
|
-
# What the last exchange did, as the live turn drew it: each saved
|
|
1665
|
-
# call's tool row (action, params, status from its result, and its
|
|
1666
|
-
# duration from +durations+, the turn's saved tool records) and a
|
|
1667
|
-
# plugin's steer where it came. The step texts stay out, as live (the
|
|
1668
|
-
# ticker's).
|
|
1669
|
-
def render_join_steps(messages, from, durations: [])
|
|
1670
|
-
records = durations.dup
|
|
1671
|
-
messages.each_with_index.drop(from + 1).each do |m, i|
|
|
1672
|
-
if Steer.steer?(m)
|
|
1673
|
-
@screen.commit(format_steer_line(source: m[:source], text: m[:content]))
|
|
1674
|
-
elsif %w[model assistant].include?(m[:role].to_s)
|
|
1675
|
-
responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
|
|
1676
|
-
Array(MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
|
|
1677
|
-
record = join_record(records, part)
|
|
1678
|
-
duration = record&.dig("duration_ms")
|
|
1679
|
-
@screen.commit(join_tool_line(part, duration_ms: duration.is_a?(Numeric) ? duration : nil,
|
|
1680
|
-
saved_status: record&.dig("status"),
|
|
1681
|
-
auto_allowed: record&.dig("guardrail")))
|
|
1682
|
-
end
|
|
1683
|
-
end
|
|
1684
|
-
end
|
|
1685
|
-
end
|
|
1686
|
-
|
|
1687
|
-
# The prompt's turn's saved tool records (AttachLink: SessionMetrics,
|
|
1688
|
-
# in this machine's state dir, as #attach_images): none for a prompt
|
|
1689
|
-
# with no turn id (an older session) or a session with no analytics.
|
|
1690
|
-
def join_tool_records(prompt)
|
|
1691
|
-
@link.saved_tool_records(prompt[:turn_id] || prompt["turn_id"])
|
|
1692
|
-
end
|
|
1693
|
-
|
|
1694
|
-
# The turn's saved +record+ when it failed with the fields its line is
|
|
1695
|
-
# made of (SessionMetrics#failure_fields), else nil: a completed or
|
|
1696
|
-
# older turn — or one saved before the failure field — draws its
|
|
1697
|
-
# answer as before (a canceled one its line).
|
|
1698
|
-
def join_failed_record(record)
|
|
1699
|
-
return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
|
|
1700
|
-
|
|
1701
|
-
failure = record["failure"]
|
|
1702
|
-
return nil if failure["summary"].to_s.empty? && failure["message"].to_s.empty?
|
|
1703
|
-
|
|
1704
|
-
record
|
|
1705
|
-
end
|
|
1706
|
-
|
|
1707
|
-
# The saved record of +part+'s call (its duration and status): the
|
|
1708
|
-
# next record of its tool, in call order, consumed with the ones it
|
|
1709
|
-
# skipped. By tool and order, not by iteration: an empty retry spends
|
|
1710
|
-
# an iteration that left no message (as the web's timing.js
|
|
1711
|
-
# takeRecord). nil for a call that left none: it never finished, or a
|
|
1712
|
-
# worker before turn ids saved it.
|
|
1713
|
-
def join_record(records, part)
|
|
1714
|
-
return nil if part[:output].nil?
|
|
1715
|
-
|
|
1716
|
-
at = records.index { |r| r["tool"].to_s == part[:tool].to_s }
|
|
1717
|
-
return nil unless at
|
|
1718
|
-
|
|
1719
|
-
records.shift(at + 1).last
|
|
1720
|
-
end
|
|
1721
|
-
|
|
1722
|
-
# A saved call's row; "no result" for a call the turn never answered
|
|
1723
|
-
# (it ended first). Its status is the saved record's, as live and the
|
|
1724
|
-
# web's reload (a guardrail's "blocked" is no error), else read from
|
|
1725
|
-
# its result (a guardrail's deny text reads blocked too). A record that
|
|
1726
|
-
# System 1 allowed once (+auto_allowed+, its saved {rule, p}) says so,
|
|
1727
|
-
# as the live row does.
|
|
1728
|
-
def join_tool_line(part, duration_ms: nil, saved_status: nil, auto_allowed: nil)
|
|
1729
|
-
output = part[:output]
|
|
1730
|
-
command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
|
|
1731
|
-
text = ToolActivity.saved_result_text(output) unless output.nil?
|
|
1732
|
-
status = if output.nil?
|
|
1733
|
-
"no result"
|
|
1734
|
-
elsif !saved_status.to_s.empty?
|
|
1735
|
-
saved_status.to_s
|
|
1736
|
-
elsif ToolActivity.guardrail_denied?(text)
|
|
1737
|
-
"blocked"
|
|
1738
|
-
else
|
|
1739
|
-
ToolActivity.tool_activity_status(text, part[:tool], command: command)
|
|
1740
|
-
end
|
|
1741
|
-
activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
|
|
1742
|
-
params: part[:params], status: status,
|
|
1743
|
-
description: ToolView.description_title(part[:view] || {}) }
|
|
1744
|
-
"#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
|
|
1745
|
-
"#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}" \
|
|
1746
|
-
"#{auto_allowed_suffix(auto_allowed)}"
|
|
1747
|
-
end
|
|
1748
|
-
|
|
1749
|
-
# The live row's System 1 suffix for a saved record's {rule, p}.
|
|
1750
|
-
def auto_allowed_suffix(saved)
|
|
1751
|
-
return "" unless saved.is_a?(Hash)
|
|
1752
|
-
|
|
1753
|
-
format_tool_guardrail_suffix({ decided_by: "system one", p: saved["p"] || saved[:p] })
|
|
1754
|
-
end
|
|
1755
|
-
|
|
1756
|
-
# The context notes that came since the last prompt (all of them in a
|
|
1757
|
-
# session with none), after the exchange the header shows.
|
|
1758
|
-
def render_join_notes(messages, after_exchange: false)
|
|
1759
|
-
last_user = messages.rindex { |m| Steer.prompt?(m) }
|
|
1760
|
-
return if after_exchange != !last_user.nil?
|
|
1761
|
-
|
|
1762
|
-
messages[(last_user ? last_user + 1 : 0)..].select { |m| ContextNote.note?(m) }.each do |m|
|
|
1763
|
-
@screen.commit(context_note_line(ContextNote.label_of(m), ContextNote.text_of(m)))
|
|
1764
|
-
end
|
|
1765
|
-
end
|
|
1766
|
-
|
|
1767
|
-
# The end of a long answer; the whole of it is in the session.
|
|
1768
|
-
def last_lines(text)
|
|
1769
|
-
lines = text.split("\n", -1)
|
|
1770
|
-
if lines.size > JOIN_ANSWER_LINES
|
|
1771
|
-
text = "(… #{lines.size - JOIN_ANSWER_LINES} earlier lines)\n#{lines.last(JOIN_ANSWER_LINES).join("\n")}"
|
|
1772
|
-
end
|
|
1773
|
-
return text if text.length <= JOIN_ANSWER_CHARS
|
|
1774
|
-
|
|
1775
|
-
"(… earlier text)\n…#{text[-JOIN_ANSWER_CHARS..]}"
|
|
1776
|
-
end
|
|
1777
|
-
|
|
1778
|
-
# The snapshot's cards and between-turns notices (Bridge CardStore):
|
|
1779
|
-
# on join the ones since the last turn (and the running turn's), as
|
|
1780
|
-
# the join header shows the last exchange; on a resync only cards not
|
|
1781
|
-
# shown here yet, and not the ones an earlier worker saved (a resync
|
|
1782
|
-
# onto a new worker: earlier turns, not news). A turn's own rows (notices, retries) are left out:
|
|
1783
|
-
# the running turn's come with its parts, and the join shows no older
|
|
1784
|
-
# turn's steps.
|
|
1785
|
-
def render_snapshot_cards(entries, joining:)
|
|
1786
|
-
entries.each do |entry|
|
|
1787
|
-
case entry[:type].to_s
|
|
1788
|
-
when "hook_notice"
|
|
1789
|
-
next if entry[:in_turn]
|
|
1790
|
-
|
|
1791
|
-
@screen.commit(EventRenderer.hook_notice_line(entry)) if joining && entry[:turns_since].to_i.zero?
|
|
1792
|
-
when "card"
|
|
1793
|
-
next unless joining ? entry[:turns_since].to_i.zero? : !entry[:earlier] && !@renderer.card_shown?(entry[:id])
|
|
1794
|
-
|
|
1795
|
-
@renderer.render_card(entry, updated: entry[:updated] == true)
|
|
1796
|
-
end
|
|
1797
|
-
end
|
|
1798
|
-
end
|
|
1799
|
-
|
|
1800
593
|
def render_current_turn(turn)
|
|
1801
594
|
@running = !turn.nil?
|
|
1802
595
|
@joined_mid_turn = @running
|
|
@@ -1852,36 +645,6 @@ module Samagotchi
|
|
|
1852
645
|
images: event[:images], diff: event[:diff] }))
|
|
1853
646
|
end
|
|
1854
647
|
|
|
1855
|
-
def show_enqueued(event)
|
|
1856
|
-
return if own?(event[:client_id])
|
|
1857
|
-
|
|
1858
|
-
# Another client's prompt shows here, before its turn starts.
|
|
1859
|
-
mark_activity
|
|
1860
|
-
@shown_enqueued << event[:enqueued_id]
|
|
1861
|
-
line = prompt_line(event[:client_id], event[:prompt], **author_of(event[:author]))
|
|
1862
|
-
@screen.commit(event[:delivery] == Delivery::QUEUE ? "#{line} #{QUEUED_NOTE}" : line)
|
|
1863
|
-
end
|
|
1864
|
-
|
|
1865
|
-
# A queued prompt was taken back before it ran (the web's ×, /unqueue
|
|
1866
|
-
# in a terminal). One of this run's (wait_at_eof) will have no turn:
|
|
1867
|
-
# it ends the wait, as a failed one does. Another client's shown
|
|
1868
|
-
# line says so.
|
|
1869
|
-
def turn_dequeued(event)
|
|
1870
|
-
id = event[:enqueued_id]
|
|
1871
|
-
@my_queued.delete(id)
|
|
1872
|
-
if @open_ids.delete?(id)
|
|
1873
|
-
if own?(event[:client_id])
|
|
1874
|
-
@own_empty = true
|
|
1875
|
-
@screen.commit("(removed before it ran)")
|
|
1876
|
-
else
|
|
1877
|
-
@own_failed = true
|
|
1878
|
-
@screen.commit("(your queued message was removed before it ran)")
|
|
1879
|
-
end
|
|
1880
|
-
elsif @shown_enqueued.delete?(id)
|
|
1881
|
-
@screen.commit("(a queued message was removed)")
|
|
1882
|
-
end
|
|
1883
|
-
end
|
|
1884
|
-
|
|
1885
648
|
def start_turn(event)
|
|
1886
649
|
@running = true
|
|
1887
650
|
@joined_mid_turn = false
|