samagotchi 0.102.0 → 0.104.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 (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -1
  3. data/bin/chi +60 -191
  4. data/docs/architecture.md +67 -1
  5. data/docs/cli.md +190 -9
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +4 -2
  8. data/docs/internals/context-telemetry.md +6 -2
  9. data/docs/internals/engine.md +5 -2
  10. data/docs/internals/llm-context-forget.md +11 -1
  11. data/docs/internals/participants.md +137 -0
  12. data/docs/internals/remote-sessions.md +429 -0
  13. data/docs/internals/working-set.md +95 -11
  14. data/docs/plugins.md +33 -3
  15. data/docs/sessions.md +16 -7
  16. data/docs/sub-agent.md +16 -0
  17. data/docs/testing.md +68 -4
  18. data/lib/samagotchi/answer_command.rb +27 -25
  19. data/lib/samagotchi/bridge.rb +31 -7
  20. data/lib/samagotchi/bridge_client/event_stream.rb +86 -12
  21. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  22. data/lib/samagotchi/bridge_client.rb +26 -39
  23. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  24. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  25. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  26. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  27. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  28. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  29. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  30. data/lib/samagotchi/chi_invocation.rb +40 -0
  31. data/lib/samagotchi/cli/command.rb +3 -2
  32. data/lib/samagotchi/cli/flags.rb +5 -0
  33. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  34. data/lib/samagotchi/context_command.rb +80 -73
  35. data/lib/samagotchi/context_status.rb +15 -2
  36. data/lib/samagotchi/engine.rb +28 -14
  37. data/lib/samagotchi/guardrail_wiring.rb +7 -1
  38. data/lib/samagotchi/idle_recap.rb +8 -2
  39. data/lib/samagotchi/idle_scheduler.rb +34 -9
  40. data/lib/samagotchi/kernel_loop.rb +21 -2
  41. data/lib/samagotchi/launch_mode.rb +26 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +8 -1
  43. data/lib/samagotchi/llm_context_forget.rb +13 -6
  44. data/lib/samagotchi/note_command.rb +8 -7
  45. data/lib/samagotchi/owner_lock.rb +65 -5
  46. data/lib/samagotchi/parent_report.rb +55 -43
  47. data/lib/samagotchi/participants.rb +122 -0
  48. data/lib/samagotchi/remote_attach_command.rb +119 -0
  49. data/lib/samagotchi/remote_command.rb +170 -0
  50. data/lib/samagotchi/remotes.rb +130 -0
  51. data/lib/samagotchi/run_options.rb +245 -0
  52. data/lib/samagotchi/send_command/busy.rb +58 -0
  53. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  54. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  55. data/lib/samagotchi/send_command.rb +173 -108
  56. data/lib/samagotchi/session.rb +38 -9
  57. data/lib/samagotchi/session_access/contract.rb +167 -0
  58. data/lib/samagotchi/session_access/dispatch.rb +152 -0
  59. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  60. data/lib/samagotchi/session_access/local.rb +400 -0
  61. data/lib/samagotchi/session_access/remote.rb +734 -0
  62. data/lib/samagotchi/session_access/web_bridge_client.rb +236 -0
  63. data/lib/samagotchi/session_access.rb +223 -0
  64. data/lib/samagotchi/session_archive_command.rb +9 -5
  65. data/lib/samagotchi/session_commands.rb +20 -15
  66. data/lib/samagotchi/session_continue.rb +1 -1
  67. data/lib/samagotchi/session_delete_command.rb +8 -4
  68. data/lib/samagotchi/session_inbox.rb +18 -9
  69. data/lib/samagotchi/session_manager.rb +27 -6
  70. data/lib/samagotchi/session_setup.rb +11 -6
  71. data/lib/samagotchi/sessions_command.rb +36 -14
  72. data/lib/samagotchi/share_command.rb +174 -0
  73. data/lib/samagotchi/terminal_ui/attach_launcher.rb +66 -17
  74. data/lib/samagotchi/terminal_ui/attach_link.rb +114 -0
  75. data/lib/samagotchi/terminal_ui/attached_loop.rb +155 -87
  76. data/lib/samagotchi/terminal_ui/formatting.rb +13 -1
  77. data/lib/samagotchi/terminal_ui/local_link.rb +135 -0
  78. data/lib/samagotchi/terminal_ui/remote_connector.rb +66 -0
  79. data/lib/samagotchi/terminal_ui/remote_link.rb +245 -0
  80. data/lib/samagotchi/terminal_ui/version_lines.rb +28 -3
  81. data/lib/samagotchi/terminal_ui.rb +6 -3
  82. data/lib/samagotchi/testing/waiting.rb +92 -9
  83. data/lib/samagotchi/title_command.rb +9 -11
  84. data/lib/samagotchi/tools/task_runtime.rb +17 -3
  85. data/lib/samagotchi/version.rb +1 -1
  86. data/lib/samagotchi/web/app.rb +530 -126
  87. data/lib/samagotchi/web/grants.rb +302 -0
  88. data/lib/samagotchi/web/participant_access.rb +286 -0
  89. data/lib/samagotchi/web/principal.rb +29 -0
  90. data/lib/samagotchi/web/public/data.js +3 -2
  91. data/lib/samagotchi/web/public/index.html +20 -4
  92. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  93. data/lib/samagotchi/web/public/stage_view.js +3 -2
  94. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  95. data/lib/samagotchi/web/public/working_set.js +159 -28
  96. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  97. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  98. data/lib/samagotchi/web/server.rb +3 -1
  99. data/lib/samagotchi/web/session_access_routes.rb +459 -0
  100. data/lib/samagotchi/worker.rb +26 -5
  101. data/lib/samagotchi/worker_sidecar.rb +12 -5
  102. data/lib/samagotchi/working_set.rb +88 -14
  103. data/lib/samagotchi/working_set_command.rb +133 -30
  104. data/lib/samagotchi/working_set_pin.rb +34 -0
  105. data/lib/samagotchi/working_set_steer.rb +191 -27
  106. metadata +28 -1
@@ -14,6 +14,7 @@ require_relative "line_reader"
14
14
  require_relative "question_prompt"
15
15
  require_relative "reline_seam"
16
16
  require_relative "version_lines"
17
+ require_relative "local_link"
17
18
  require_relative "../bridge/turn_accumulator"
18
19
  require_relative "../bridge_client"
19
20
  require_relative "../delivery"
@@ -45,8 +46,6 @@ module Samagotchi
45
46
  include InputSupport
46
47
 
47
48
  PROMPT = "> "
48
- # How long /exit --delete waits for the worker to let go.
49
- DELETE_WAIT = 10
50
49
  # A second Ctrl-C at an empty idle prompt within this many seconds detaches.
51
50
  DETACH_WINDOW = 2.0
52
51
  DETACH_HINT = "Ctrl-D to detach, /exit stops the worker"
@@ -84,7 +83,8 @@ module Samagotchi
84
83
  # waiting when it ended :unanswered
85
84
  attr_reader :unanswered
86
85
 
87
- # @param client [BridgeClient]
86
+ # @param client [BridgeClient] the session's on this machine (a
87
+ # LocalLink is made of it and the keywords below), or nil with +link+
88
88
  # @param screen [Surface] with #synchronize and #columns (a Screen, or a
89
89
  # PlainSurface when the terminal can't show a live region)
90
90
  # @param client_id [String] this UI's id in the events ("tui:<pid>")
@@ -108,19 +108,20 @@ module Samagotchi
108
108
  # ChildrenStatus::Counts, the status row's children segment
109
109
  # @param state_dir [String] the state dir the worker was found in: its
110
110
  # session folder (@path images, saved records) is under it
111
- def initialize(client:, screen:, client_id:, first_prompt: nil, first_command: nil, no_interrupt: false,
111
+ # @param link [AttachLink, nil] where the session is, instead of
112
+ # +client+ and the keywords that make a LocalLink
113
+ def initialize(screen:, client_id:, client: nil, first_prompt: nil, first_command: nil, no_interrupt: false,
112
114
  default_input: false, wait_at_eof: false, as_parent: false,
113
115
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
114
- delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
115
- archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
116
+ delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: LocalLink::DELETE_WAIT) },
117
+ archive_session: ->(id) { SessionManager.archive_session(id, wait: LocalLink::DELETE_WAIT) },
116
118
  installed_version: -> { InstalledVersions.new.newest },
117
119
  state_dir: Session.default_state_dir,
118
- children_counts: ->(id) { ChildrenStatus.counts(id, state_dir: state_dir) })
119
- @client = client
120
- @state_dir = state_dir
121
- @installed_version = installed_version
122
- @delete_session = delete_session
123
- @archive_session = archive_session
120
+ children_counts: ->(id) { ChildrenStatus.counts(id, state_dir: state_dir) }, link: nil)
121
+ @link = link || LocalLink.new(client: client, state_dir: state_dir, delete_session: delete_session,
122
+ archive_session: archive_session, installed_version: installed_version,
123
+ children_counts: children_counts)
124
+ @client = @link.client
124
125
  @screen = screen
125
126
  @client_id = client_id
126
127
  # A parent agent drives this attach (AttachLauncher:
@@ -170,7 +171,6 @@ module Samagotchi
170
171
  @continue_offer = nil
171
172
  # The status row: the worker's model, ctx, the session's memories.
172
173
  @status = StatusRow.new(screen)
173
- @children_counts = children_counts
174
174
  end
175
175
 
176
176
  def running? = @running
@@ -197,7 +197,7 @@ module Samagotchi
197
197
  # A new worker (a restart) is followed, not lost: its sidecar is
198
198
  # looked for whenever the stream drops.
199
199
  stream = @client.follow(client_id: @client_id, rediscover: method(:live_worker),
200
- reconnect_delays: BridgeClient::EventStream::REDISCOVER_DELAYS) do |event|
200
+ reconnect_delays: @link.reconnect_delays) do |event|
201
201
  queue << [:event, event]
202
202
  end
203
203
  @reader = LineReader.new(queue, prompt: method(:prompt_text), read: input || method(:read_input_line),
@@ -335,10 +335,13 @@ module Samagotchi
335
335
  @status.take_event(event)
336
336
  @renderer.call(event)
337
337
  when :worker_changed then switch_worker(event)
338
+ # A stream through chi web (never a Bridge here): a reconnect failed,
339
+ # and the next frame came.
340
+ when :stream_retrying then @screen.commit("(lost the connection to #{via.remote || "the worker"}; reconnecting…)")
341
+ when :stream_resumed then @screen.commit("(reconnected)")
338
342
  when :stream_closed
339
343
  @view.finish_thinking_spinner
340
- @screen.commit("Lost the session's worker (#{event[:reason]}). " \
341
- "Resume it with: chi --shared --resume #{@client.session_id}")
344
+ @screen.commit(closed_line(event[:reason].to_s))
342
345
  return :closed
343
346
  else @renderer.call(event)
344
347
  end
@@ -349,30 +352,56 @@ module Samagotchi
349
352
 
350
353
  # The newest chi installed, looked at once, at the join.
351
354
  def installed_version
352
- @installed_version.call
355
+ @link.installed_version
353
356
  rescue StandardError
354
357
  nil
355
358
  end
356
359
 
357
- # The session's live worker for the stream to follow (EventStream's
358
- # rediscover): nil while there is none yet, :gone once the session was
359
- # stopped (no worker is coming).
360
- def live_worker
361
- id = @client.session_id
362
- return :gone if Session.stopped_marker?(id)
360
+ # How the commands this TUI prints are spelled (AttachLink#via).
361
+ def via = @link.via
363
362
 
364
- BridgeClient.discover(id, session_dir: Session.session_dir(id, state_dir: @state_dir), host: @client.host)
365
- end
363
+ # The session's live worker for the stream to follow (EventStream's
364
+ # rediscover), with its reason (AttachLink#live_worker's pair): the
365
+ # stream's closing event carries the last one (#closed_line).
366
+ def live_worker = @link.live_worker
366
367
 
367
368
  # The stream moved to a new worker (a restart): requests go there too.
368
369
  def switch_worker(event)
369
- @client = BridgeClient.new(session_id: @client.session_id, port: event[:port], host: @client.host)
370
+ @client = @link.switched(event)
370
371
  @worker_changed = true
372
+ # The new worker's machine may name another default model.
373
+ @default_model_name = nil
371
374
  @view.finish_thinking_spinner
372
- Log.info(:attached, "worker_changed", port: event[:port])
375
+ Log.info(:attached, "worker_changed", port: AttachLink.event_port(event), epoch: event[:epoch])
373
376
  nil
374
377
  end
375
378
 
379
+ # The stream ended for good: why, and what brings the session back.
380
+ def closed_line(reason)
381
+ id = @client.session_id
382
+ return remote_closed_line(reason, id) if via.remote?
383
+
384
+ # Here a stopped session has always read as unreachable.
385
+ reason = "unreachable" if reason == "stopped"
386
+ "Lost the session's worker (#{reason}). Resume it with: #{via.command("--shared --resume #{id}")}"
387
+ end
388
+
389
+ # Each reason in its own words (the remote's name, its chi web's
390
+ # failure as AttachLink#failure gave it).
391
+ def remote_closed_line(reason, id)
392
+ name = via.remote
393
+ failure = @link.failure
394
+ case reason
395
+ when "stopped" then "The session was stopped on #{name}. Resume it with: #{via.resume(id)}"
396
+ when "auth_failed", "too_old" then "Lost the session: #{failure || reason}"
397
+ when "worker_gone" then "The session's worker on #{name} is gone. Resume it with: #{via.resume(id)}"
398
+ when "unknown_session" then "The session is gone from #{name} (deleted)."
399
+ else
400
+ cause = failure&.sub(/\Aremote \S+ \([^)]*\): /, "")
401
+ "Lost the connection to #{name}#{" (#{cause})" if cause}. Re-attach with: #{via.attach(id)}"
402
+ end
403
+ end
404
+
376
405
  def next_item(queue)
377
406
  queue.pop
378
407
  rescue Interrupt
@@ -390,7 +419,7 @@ module Samagotchi
390
419
 
391
420
  # @return [Symbol, nil] :detach to end the loop
392
421
  def submit(line)
393
- return detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if line.nil?
422
+ return detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if line.nil?
394
423
 
395
424
  text = line.strip
396
425
  if @question
@@ -401,6 +430,31 @@ module Samagotchi
401
430
  return answer_question(text)
402
431
  end
403
432
 
433
+ text, kept = split_prefilled(text)
434
+ submitted = submit_line(text)
435
+ # The removed text the command was typed after, back in the input
436
+ # (unless an /unqueue put its own there, or the loop ends).
437
+ @prefilled = kept if kept && submitted != :detach && @prefilled.nil? && @reader&.prefill(kept)
438
+ submitted
439
+ end
440
+
441
+ # A line typed after an /unqueue's text (#unqueued) without clearing
442
+ # it, the text untouched and then a command ("fix it/unqueue"): the
443
+ # command alone, and the text to put back. Any other line as is.
444
+ # @return [Array(String, String|nil)]
445
+ def split_prefilled(text)
446
+ kept = @prefilled
447
+ @prefilled = nil
448
+ return [text, nil] unless kept && text.length > kept.length && text.start_with?(kept)
449
+
450
+ rest = text[kept.length..].strip
451
+ route = ComposerRoute.call(rest, running: @running, registry: command_registry)
452
+ command = route.choice || %i[local command usage].include?(route.kind)
453
+ command ? [rest, kept] : [text, nil]
454
+ end
455
+
456
+ # @return [Symbol, nil] :detach to end the loop
457
+ def submit_line(text)
404
458
  # /cut TEXT and /queue TEXT are this UI's own deliveries, read before
405
459
  # every command check (ComposerRoute; SessionCommands registers them
406
460
  # for Tab and /help only). A plain line sends no delivery: it goes in
@@ -455,20 +509,18 @@ module Samagotchi
455
509
  # @return [nil]
456
510
  def submit_delivery(route)
457
511
  return @screen.commit(STEER_CHOICE_USAGE) if route.kind == :usage
458
- return @screen.commit(QUEUE_NEEDS_NEWER_WORKER) if Delivery.queue?(route.delivery) && !worker_queues?
512
+ return @screen.commit(queue_needs_newer_worker) if Delivery.queue?(route.delivery) && !worker_queues?
459
513
 
460
- # To an idle session it is an ordinary prompt: no delivery to send.
461
- send_prompt(route.text, delivery: route.delivery)
514
+ # To an idle session it is an ordinary prompt: no delivery to send,
515
+ # and its ack says it was sent now (#sent_note).
516
+ send_prompt(route.text, delivery: route.delivery, choice: route.choice)
462
517
  nil
463
518
  end
464
519
 
465
520
  # Whether the attached worker runs delivery: queue (its sidecar names
466
521
  # Delivery::QUEUE_FEATURE). No sidecar to read: true, its Bridge
467
522
  # answers (a P0-P2 worker refuses a queue: #send_prompt).
468
- def worker_queues?
469
- sidecar = WorkerSidecar.read(Session.session_dir(@client.session_id, state_dir: @state_dir))
470
- sidecar.nil? || sidecar.features.include?(Delivery::QUEUE_FEATURE)
471
- end
523
+ def worker_queues? = @link.queues?
472
524
 
473
525
  # @return [Symbol] :detach
474
526
  def detach(line)
@@ -502,12 +554,13 @@ module Samagotchi
502
554
  # An empty session: the worker deletes it as it leaves.
503
555
  return detach(EMPTY_EXIT) if discards?(reply)
504
556
 
505
- @delete_session.call(id)
557
+ @link.delete_session
506
558
  detach("Detached; deleted session #{id}.")
507
559
  rescue SystemCallError, IOError => e
508
560
  detach("#{exit_failed_line(e.message)} Not deleted.")
509
561
  rescue SessionManager::DeleteRefused, SessionManager::OwnedByTUI, ArgumentError => e
510
- detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): chi sessions delete #{id}")
562
+ detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): " \
563
+ "#{via.command("sessions delete #{id}")}")
511
564
  end
512
565
 
513
566
  # /archive during a turn (D2): it waits for the turn's end and the
@@ -540,14 +593,14 @@ module Samagotchi
540
593
  return detach(EMPTY_EXIT) if reply.status == 200 && discards?(reply)
541
594
  return detach("#{exit_line(reply)} Not archived.") unless [200, 409].include?(reply.status)
542
595
 
543
- result = @archive_session.call(id)
596
+ result = @link.archive_session
544
597
  return detach("Detached; the session was empty, so it is discarded.") if result[:discarded].include?(id)
545
598
 
546
- detach("Detached; archived session #{id}. chi sessions list --archived finds it.")
599
+ detach("Detached; archived session #{id}. #{via.command("sessions list --archived")} finds it.")
547
600
  rescue SystemCallError, IOError => e
548
601
  detach("#{exit_failed_line(e.message)} Not archived.")
549
602
  rescue SessionManager::ArchiveRefused, SessionManager::OwnedByTUI, ArgumentError => e
550
- detach("Detached; session #{id} was not archived (#{e.message}). Re-attach with: chi --attach #{id}")
603
+ detach("Detached; session #{id} was not archived (#{e.message}). Re-attach with: #{via.attach(id)}")
551
604
  end
552
605
 
553
606
  def exit_line(reply)
@@ -557,16 +610,18 @@ module Samagotchi
557
610
  when 200
558
611
  return EMPTY_EXIT if discards?(reply)
559
612
 
560
- "Detached; the session's worker is stopping. Resume with: chi --resume #{id}"
613
+ "Detached; the session's worker is stopping. Resume with: #{via.resume(id)}"
561
614
  when 409
562
615
  reason = reply.json&.fetch("reason", nil).to_s
563
616
  "Detached; the session keeps running (#{held_words(reason, reply.json&.fetch("tasks", nil))}). " \
564
- "Re-attach with: chi --attach #{id}"
617
+ "Re-attach with: #{via.attach(id)}"
565
618
  when 0 then exit_failed_line("no reply")
566
619
  when 408 then exit_failed_line(LATE)
567
620
  else
568
621
  # A worker from before the route answers the Bridge's plain 404.
569
- return "Detached; this worker runs an older chi and can't be stopped from here: chi sessions stop #{id}" if error == "not_found"
622
+ if error == "not_found"
623
+ return "Detached; this worker runs an older chi and can't be stopped from here: #{via.command("sessions stop #{id}")}"
624
+ end
570
625
 
571
626
  exit_failed_line([reply.status, reply.json&.fetch("detail", nil) || error].compact.join(" "))
572
627
  end
@@ -584,7 +639,7 @@ module Samagotchi
584
639
  def discards?(reply) = reply.json&.fetch("discard", nil) == true
585
640
 
586
641
  def exit_failed_line(why)
587
- "Detached (could not ask the worker to stop: #{why}). Re-attach with: chi --attach #{@client.session_id}"
642
+ "Detached (could not ask the worker to stop: #{why}). Re-attach with: #{via.attach(@client.session_id)}"
588
643
  end
589
644
 
590
645
  # The worker says what became of it (:continue_resolved); an invalid
@@ -631,6 +686,9 @@ module Samagotchi
631
686
  # Why a Bridge request raised, for the line that says what didn't go
632
687
  # through: a stopped (or wedged) worker times out, a gone one refuses.
633
688
  def worker_down(error)
689
+ # Through chi web: the message names the remote and what failed.
690
+ return error.message if via.remote?
691
+
634
692
  head, detail = error.message.split(" - ", 2)
635
693
  return "worker not answering: #{(detail || head).sub(/\Abridge \S+: /, "")}" if error.is_a?(Errno::ETIMEDOUT)
636
694
 
@@ -639,27 +697,31 @@ module Samagotchi
639
697
 
640
698
  QUEUED_NOTE = "(queued: runs after this turn)"
641
699
  QUEUE_RUNS_NOW = "(the turn had ended: runs as the next turn)"
642
- # /queue to a worker from before queueing, which would steer it.
643
- QUEUE_NEEDS_NEWER_WORKER = "(/queue needs a newer worker: restart it with chi sessions restart, then send again)"
644
- UNQUEUE_NEEDS_NEWER_WORKER = "(/unqueue needs a newer worker: restart it with chi sessions restart)"
645
700
  NOTHING_QUEUED = "(nothing queued: /queue TEXT queues a message for after the running turn)"
646
701
  UNQUEUE_REFUSED = { "already_started" => "(not removed: it already started)",
647
702
  "unknown_enqueued_id" => "(not removed: it is already gone)",
648
703
  "not_queued" => "(not removed: it goes in at the next step)" }.freeze
649
704
 
705
+ # /queue to a worker from before queueing, which would steer it.
706
+ def queue_needs_newer_worker
707
+ "(/queue needs a newer worker: restart it with #{via.command("sessions restart")}, then send again)"
708
+ end
709
+
710
+ def unqueue_needs_newer_worker = "(/unqueue needs a newer worker: restart it with #{via.command("sessions restart")})"
711
+
650
712
  # /unqueue: take back the newest prompt this run queued for after the
651
713
  # turn, before it runs (POST turn/dequeue). Its :turn_dequeued comes
652
714
  # to every UI; here the reply says what became of it. A worker whose
653
715
  # sidecar lacks "unqueue" is not asked.
654
716
  def unqueue_newest
655
- return @screen.commit(UNQUEUE_NEEDS_NEWER_WORKER) unless worker_unqueues?
717
+ return @screen.commit(unqueue_needs_newer_worker) unless worker_unqueues?
656
718
 
657
719
  id = @my_queued.last
658
720
  return @screen.commit(NOTHING_QUEUED) unless id
659
721
 
660
722
  reply = @client.dequeue(enqueued_id: id, client_id: @client_id)
661
723
  error = reply.json&.fetch("error", nil)
662
- return @screen.commit(UNQUEUE_NEEDS_NEWER_WORKER) if reply.status == 404 && error == "not_found"
724
+ return @screen.commit(unqueue_needs_newer_worker) if reply.status == 404 && error == "not_found"
663
725
 
664
726
  # Removed, started or gone: no longer this run's to take back.
665
727
  @my_queued.delete(id) if [200, 404, 409].include?(reply.status)
@@ -678,7 +740,9 @@ module Samagotchi
678
740
  @screen.commit("removed: #{first_line_clip(prompt)}")
679
741
  return if @wait_at_eof || prompt.strip.empty?
680
742
 
681
- @screen.commit("(the removed message is in the input history: ↑)") unless @reader&.prefill(prompt)
743
+ return @prefilled = prompt.strip if @reader&.prefill(prompt)
744
+
745
+ @screen.commit("(the removed message is in the input history: ↑)")
682
746
  end
683
747
 
684
748
  def first_line_clip(text, max = 60)
@@ -689,10 +753,7 @@ module Samagotchi
689
753
  # Whether the attached worker takes queued messages back (its sidecar
690
754
  # names SessionInbox::UNQUEUE_FEATURE). No sidecar to read: true, its Bridge
691
755
  # answers (an older one's 404 not_found says so).
692
- def worker_unqueues?
693
- sidecar = WorkerSidecar.read(Session.session_dir(@client.session_id, state_dir: @state_dir))
694
- sidecar.nil? || sidecar.features.include?(SessionInbox::UNQUEUE_FEATURE)
695
- end
756
+ def worker_unqueues? = @link.unqueues?
696
757
 
697
758
  # A command queued for after the running turn (command_queued with
698
759
  # waits): its line now (another client's) and a note; its command_ran
@@ -803,11 +864,13 @@ module Samagotchi
803
864
  # from the ack (the worker knows whether cutting is on for its model);
804
865
  # a queued one waits for the turn's end (the ack says queue).
805
866
  # An idle session's prompt simply runs, and a prompt with images runs
806
- # as its own next turn (never merging mid-turn), so nothing is said.
867
+ # as its own next turn (never merging mid-turn), so nothing is said;
868
+ # one typed as /cut or /queue says it was sent now (+choice+).
807
869
  # @param ack [Hash, nil] the Bridge's 202 body
808
870
  # @param delivery [String, nil] what was asked for
809
871
  # @return [String, nil]
810
- def sent_note(ack, delivery: nil, images: [], running: false)
872
+ def sent_note(ack, delivery: nil, images: [], running: false, choice: nil)
873
+ return STEER_CHOICE_IDLE if choice && !running
811
874
  return nil unless running && ack.is_a?(Hash)
812
875
  # The Bridge acks queue only while a turn ran at enqueue; a /queue
813
876
  # it acks next_step found the turn over: it is the next turn.
@@ -834,7 +897,8 @@ module Samagotchi
834
897
  @client_id
835
898
  end
836
899
 
837
- def send_prompt(text, delivery: nil)
900
+ # @param choice [Symbol, nil] :cut or :queue when typed with that word
901
+ def send_prompt(text, delivery: nil, choice: nil)
838
902
  persist_recent_history(text)
839
903
  images = attach_images(text)
840
904
  return own_prompt_failed if images.nil?
@@ -850,7 +914,7 @@ module Samagotchi
850
914
  @sent_ids << enqueued_id if enqueued_id
851
915
  @open_ids << enqueued_id if enqueued_id && @wait_at_eof
852
916
  @my_queued << enqueued_id if enqueued_id && reply.json&.fetch("delivery", nil) == Delivery::QUEUE
853
- note = sent_note(reply.json, delivery: delivery, images: images, running: running)
917
+ note = sent_note(reply.json, delivery: delivery, images: images, running: running, choice: choice)
854
918
  @screen.commit(note) if note
855
919
  return
856
920
  end
@@ -861,7 +925,7 @@ module Samagotchi
861
925
  # Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
862
926
  return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
863
927
  # A worker from 0.51 to 0.63 refuses a queue (nothing was sent).
864
- return @screen.commit(QUEUE_NEEDS_NEWER_WORKER) if detail == "delivery_unavailable"
928
+ return @screen.commit(queue_needs_newer_worker) if detail == "delivery_unavailable"
865
929
 
866
930
  @screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})")
867
931
  rescue SystemCallError, IOError => e
@@ -905,7 +969,7 @@ module Samagotchi
905
969
 
906
970
  # The input ended and nothing of ours is left to wait for.
907
971
  def end_of_input
908
- detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if @input_ended
972
+ detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if @input_ended
909
973
  return :turn_failed if @own_failed
910
974
 
911
975
  @own_empty ? :empty_answer : :detached
@@ -913,31 +977,30 @@ module Samagotchi
913
977
 
914
978
  def unanswered_question(pending)
915
979
  @unanswered = pending
916
- detach("A question waits for an answer: chi --attach #{@client.session_id}")
980
+ detach("A question waits for an answer: #{via.attach(@client.session_id)}")
917
981
  :unanswered
918
982
  end
919
983
 
920
- # The prompt's `@path` images, stored in the session's images/ here
921
- # (this machine, the worker's state dir) and sent as refs: the Bridge
922
- # never takes a path. nil (after a line saying why) when one can't be.
984
+ # The prompt's `@path` images, stored where the worker reads them
985
+ # (AttachLink#ingest_images: the session's images/ here) and sent as
986
+ # refs: the Bridge never takes a path. nil (after a line saying why)
987
+ # when one can't be.
923
988
  def attach_images(text)
924
989
  paths = ImageInput.extract(text)
925
990
  return [] if paths.empty?
926
991
 
927
- session_dir = Session.session_dir(@client.session_id, state_dir: @state_dir)
928
- paths.map do |image|
929
- ref = ImageStore.ingest(session_dir, path: image[:path])
930
- { file: ref[:file], name: ref[:name] }
931
- end
992
+ @link.ingest_images(paths.map { |image| image[:path] })
932
993
  rescue ImageStore::Error => e
933
994
  @screen.commit("could not attach the image: #{e.message}")
934
995
  nil
935
996
  end
936
997
 
937
998
  def show_stats
938
- # A worker from before the stats route answers only /state.
939
- metrics = @client.get_json("stats")&.dig("metrics") || @client.get_json("state")&.dig("session_state_snapshot", "metrics")
999
+ metrics = @link.stats_metrics
940
1000
  @screen.commit(metrics ? format_session_metrics(EventRenderer.deep_symbolize_keys(metrics)) : "(no metrics: the worker did not answer)")
1001
+ rescue SystemCallError, IOError => e
1002
+ # Through chi web: the message names the remote and what failed.
1003
+ @screen.commit("(no metrics: #{via.remote? ? e.message : "the worker did not answer"})")
941
1004
  end
942
1005
 
943
1006
  # One read on the reader thread: the REPL's multiline read with Tab
@@ -955,6 +1018,16 @@ module Samagotchi
955
1018
  # The attached TUI's commands, for Tab.
956
1019
  def slash_commands = command_registry.completions(:attached)
957
1020
 
1021
+ # @path and #memory candidates, as the link offers them
1022
+ # (AttachLink#completions).
1023
+ def build_project_path_completion_candidates(path_fragment)
1024
+ @link.completions.paths ? super : []
1025
+ end
1026
+
1027
+ def build_memory_completion_candidates(memory_fragment)
1028
+ @link.completions.memories ? super : []
1029
+ end
1030
+
958
1031
  # The session's commands as its worker's snapshot names them (its
959
1032
  # plugins' too); the built-ins until one arrives, or from a worker too
960
1033
  # old to name them. An unknown /foo is a prompt, as in the REPL.
@@ -970,8 +1043,8 @@ module Samagotchi
970
1043
  @screen.commit(recap_command_text(enabled: answer["enabled"], saved: answer["saved"],
971
1044
  request: answer["request"], min_user_turns: answer["min_user_turns"],
972
1045
  session_off: answer["session_off"] == true))
973
- rescue SystemCallError, IOError
974
- @screen.commit("(no recap: the worker did not answer)")
1046
+ rescue SystemCallError, IOError => e
1047
+ @screen.commit("(no recap: #{via.remote? ? e.message : "the worker did not answer"})")
975
1048
  end
976
1049
 
977
1050
  def prompt_text
@@ -1072,7 +1145,7 @@ module Samagotchi
1072
1145
  end
1073
1146
 
1074
1147
  def stale_worker(cant)
1075
- BridgeClient.stale_worker_message(@client.session_id, cant: cant)
1148
+ BridgeClient.stale_worker_message(@client.session_id, cant: cant, via: via)
1076
1149
  end
1077
1150
 
1078
1151
  # The worker rolled a failed turn back and handed its prompt back: ours
@@ -1208,7 +1281,7 @@ module Samagotchi
1208
1281
  @screen.commit("guardrails> #{snapshot[:guardrail_warning]}") if snapshot[:guardrail_warning]
1209
1282
  @screen.commit("plugins> #{snapshot[:plugin_warning]}") if snapshot[:plugin_warning]
1210
1283
  version_line = VersionLines.at_attach(worker: snapshot[:chi_version], installed: installed_version,
1211
- session_id: @client.session_id)
1284
+ session_id: @client.session_id, via: via)
1212
1285
  @screen.commit(paint(version_line, 2)) if version_line
1213
1286
  end
1214
1287
  if snapshot[:commands]
@@ -1381,13 +1454,11 @@ module Samagotchi
1381
1454
  end
1382
1455
  end
1383
1456
 
1384
- # The prompt's turn's saved tool records (SessionMetrics, in this
1385
- # machine's state dir, as #attach_images): none for a prompt with no
1386
- # turn id (an older session) or a session with no analytics.
1457
+ # The prompt's turn's saved tool records (AttachLink: SessionMetrics,
1458
+ # in this machine's state dir, as #attach_images): none for a prompt
1459
+ # with no turn id (an older session) or a session with no analytics.
1387
1460
  def join_tool_records(prompt)
1388
- SessionMetrics.saved_tool_records(Session.session_dir(@client.session_id, state_dir: @state_dir), prompt[:turn_id] || prompt["turn_id"])
1389
- rescue Session::InvalidId
1390
- []
1461
+ @link.saved_tool_records(prompt[:turn_id] || prompt["turn_id"])
1391
1462
  end
1392
1463
 
1393
1464
  # The turn's saved record when it failed with the fields its line is
@@ -1395,16 +1466,13 @@ module Samagotchi
1395
1466
  # canceled or older turn — or one saved before the failure field —
1396
1467
  # draws its answer as before.
1397
1468
  def join_failed_record(prompt)
1398
- record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id, state_dir: @state_dir),
1399
- prompt[:turn_id] || prompt["turn_id"])
1469
+ record = @link.saved_turn_record(prompt[:turn_id] || prompt["turn_id"])
1400
1470
  return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
1401
1471
 
1402
1472
  failure = record["failure"]
1403
1473
  return nil if failure["summary"].to_s.empty? && failure["message"].to_s.empty?
1404
1474
 
1405
1475
  record
1406
- rescue Session::InvalidId
1407
- nil
1408
1476
  end
1409
1477
 
1410
1478
  # The saved record of +part+'s call (its duration and status): the
@@ -1631,14 +1699,14 @@ module Samagotchi
1631
1699
  def refresh_children
1632
1700
  return unless @status.enabled?
1633
1701
 
1634
- @status.update(children: @children_counts.call(@client.session_id))
1702
+ @status.update(children: @link.children_counts)
1635
1703
  rescue StandardError => e
1636
1704
  Log.debug(:attached, "children_counts_failed", error: e.class.name)
1637
1705
  end
1638
1706
 
1639
1707
  # The config's default model, as the worker's /model names it.
1640
1708
  def default_model_name
1641
- @default_model_name ||= ModelProfile.default_model_name
1709
+ @default_model_name ||= @link.default_model_name
1642
1710
  end
1643
1711
 
1644
1712
  def reminder_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::SYSTEM_PREFIX)
@@ -118,6 +118,9 @@ module Samagotchi
118
118
  CUT_OFF_NOTE = "(cutting is off; goes in at the next step)"
119
119
  # A bare /cut or /queue (ComposerRoute's :usage).
120
120
  STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)"
121
+ # A /cut TEXT or /queue TEXT with no turn running: the text is sent
122
+ # at once, a normal turn (ComposerRoute routes it on as a line).
123
+ STEER_CHOICE_IDLE = "(nothing is running: sent now)"
121
124
 
122
125
  # @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
123
126
  # @param after [Integer, nil] the run's cut wait (the model's own
@@ -559,11 +562,20 @@ module Samagotchi
559
562
  "#{label}: #{visible.join(", ")}#{suffix}"
560
563
  end
561
564
 
565
+ # +text+ cut to +width+ with an ellipsis, at a word (or a " | "
566
+ # between segments) when one ends in the second half, so the row never
567
+ # stops mid-word (a plain surface prints it as is).
562
568
  def cap_preview_text(text, width)
563
569
  return "" if width <= 0
564
570
 
565
571
  value = text.to_s
566
- value.length > width ? value[0, width] : value
572
+ return value if value.length <= width
573
+ return value[0, width] if width < 2
574
+
575
+ cut = value[0, width - 1]
576
+ space = cut.rindex(" ")
577
+ cut = cut[0, space] if space && space >= width / 2
578
+ "#{cut.sub(/[\s,;:|·-]+\z/, "")}…"
567
579
  end
568
580
 
569
581
  public