samagotchi 0.102.0 → 0.103.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 (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -1
  3. data/bin/chi +23 -191
  4. data/docs/architecture.md +54 -0
  5. data/docs/cli.md +114 -7
  6. data/docs/configuration.md +7 -1
  7. data/docs/guardrails.md +2 -1
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-forget.md +11 -1
  10. data/docs/internals/remote-sessions.md +303 -0
  11. data/docs/internals/working-set.md +95 -11
  12. data/docs/plugins.md +26 -2
  13. data/docs/sessions.md +12 -5
  14. data/docs/sub-agent.md +8 -0
  15. data/lib/samagotchi/answer_command.rb +27 -25
  16. data/lib/samagotchi/bridge.rb +24 -4
  17. data/lib/samagotchi/bridge_client/event_stream.rb +81 -12
  18. data/lib/samagotchi/bridge_client/stream_request.rb +77 -0
  19. data/lib/samagotchi/bridge_client.rb +22 -38
  20. data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/claude-peer/scripts/pair +30 -1
  22. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/codex-peer/scripts/pair +49 -4
  24. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +7 -4
  25. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  26. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  27. data/lib/samagotchi/chi_invocation.rb +43 -0
  28. data/lib/samagotchi/cli/command.rb +3 -2
  29. data/lib/samagotchi/cli/flags.rb +5 -0
  30. data/lib/samagotchi/cli/parent_wait.rb +22 -13
  31. data/lib/samagotchi/context_command.rb +80 -73
  32. data/lib/samagotchi/engine.rb +12 -8
  33. data/lib/samagotchi/idle_recap.rb +7 -1
  34. data/lib/samagotchi/kernel_loop.rb +13 -1
  35. data/lib/samagotchi/launch_mode.rb +22 -0
  36. data/lib/samagotchi/llm_context_forget.rb +13 -6
  37. data/lib/samagotchi/note_command.rb +8 -7
  38. data/lib/samagotchi/parent_report.rb +55 -43
  39. data/lib/samagotchi/remote_command.rb +170 -0
  40. data/lib/samagotchi/remotes.rb +130 -0
  41. data/lib/samagotchi/run_options.rb +233 -0
  42. data/lib/samagotchi/send_command/busy.rb +58 -0
  43. data/lib/samagotchi/send_command/parent_gate.rb +23 -10
  44. data/lib/samagotchi/send_command/wait_target.rb +16 -3
  45. data/lib/samagotchi/send_command.rb +173 -108
  46. data/lib/samagotchi/session.rb +30 -5
  47. data/lib/samagotchi/session_access/contract.rb +129 -0
  48. data/lib/samagotchi/session_access/dispatch.rb +133 -0
  49. data/lib/samagotchi/session_access/http_transport.rb +109 -0
  50. data/lib/samagotchi/session_access/local.rb +349 -0
  51. data/lib/samagotchi/session_access/remote.rb +638 -0
  52. data/lib/samagotchi/session_access/web_bridge_client.rb +181 -0
  53. data/lib/samagotchi/session_access.rb +141 -0
  54. data/lib/samagotchi/session_archive_command.rb +9 -5
  55. data/lib/samagotchi/session_commands.rb +20 -15
  56. data/lib/samagotchi/session_delete_command.rb +8 -4
  57. data/lib/samagotchi/session_manager.rb +7 -2
  58. data/lib/samagotchi/session_setup.rb +11 -6
  59. data/lib/samagotchi/sessions_command.rb +36 -14
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +51 -15
  61. data/lib/samagotchi/terminal_ui/attach_link.rb +104 -0
  62. data/lib/samagotchi/terminal_ui/attached_loop.rb +80 -75
  63. data/lib/samagotchi/terminal_ui/local_link.rb +130 -0
  64. data/lib/samagotchi/terminal_ui/version_lines.rb +5 -3
  65. data/lib/samagotchi/terminal_ui.rb +2 -2
  66. data/lib/samagotchi/title_command.rb +9 -11
  67. data/lib/samagotchi/version.rb +1 -1
  68. data/lib/samagotchi/web/app.rb +360 -69
  69. data/lib/samagotchi/web/public/data.js +3 -2
  70. data/lib/samagotchi/web/public/index.html +20 -4
  71. data/lib/samagotchi/web/public/stage_frame.js +7 -2
  72. data/lib/samagotchi/web/public/stage_view.js +3 -2
  73. data/lib/samagotchi/web/public/stream_handlers.js +3 -1
  74. data/lib/samagotchi/web/public/working_set.js +159 -28
  75. data/lib/samagotchi/web/public/working_set_rail.js +39 -12
  76. data/lib/samagotchi/web/public/working_set_view.js +91 -33
  77. data/lib/samagotchi/web/session_access_routes.rb +343 -0
  78. data/lib/samagotchi/worker_sidecar.rb +12 -5
  79. data/lib/samagotchi/working_set.rb +88 -14
  80. data/lib/samagotchi/working_set_command.rb +133 -30
  81. data/lib/samagotchi/working_set_pin.rb +34 -0
  82. data/lib/samagotchi/working_set_steer.rb +191 -27
  83. metadata +19 -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),
@@ -338,7 +338,7 @@ module Samagotchi
338
338
  when :stream_closed
339
339
  @view.finish_thinking_spinner
340
340
  @screen.commit("Lost the session's worker (#{event[:reason]}). " \
341
- "Resume it with: chi --shared --resume #{@client.session_id}")
341
+ "Resume it with: #{via.command("--shared --resume #{@client.session_id}")}")
342
342
  return :closed
343
343
  else @renderer.call(event)
344
344
  end
@@ -349,27 +349,30 @@ module Samagotchi
349
349
 
350
350
  # The newest chi installed, looked at once, at the join.
351
351
  def installed_version
352
- @installed_version.call
352
+ @link.installed_version
353
353
  rescue StandardError
354
354
  nil
355
355
  end
356
356
 
357
+ # How the commands this TUI prints are spelled (AttachLink#via).
358
+ def via = @link.via
359
+
357
360
  # 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).
361
+ # rediscover): nil while there is none yet, :gone once no worker is
362
+ # coming (AttachLink#live_worker). The reason isn't passed on: the
363
+ # stream closes as "unreachable", the words a stopped session has
364
+ # always had here.
360
365
  def live_worker
361
- id = @client.session_id
362
- return :gone if Session.stopped_marker?(id)
363
-
364
- BridgeClient.discover(id, session_dir: Session.session_dir(id, state_dir: @state_dir), host: @client.host)
366
+ found, _reason = @link.live_worker
367
+ found
365
368
  end
366
369
 
367
370
  # The stream moved to a new worker (a restart): requests go there too.
368
371
  def switch_worker(event)
369
- @client = BridgeClient.new(session_id: @client.session_id, port: event[:port], host: @client.host)
372
+ @client = @link.switched(event)
370
373
  @worker_changed = true
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))
373
376
  nil
374
377
  end
375
378
 
@@ -390,7 +393,7 @@ module Samagotchi
390
393
 
391
394
  # @return [Symbol, nil] :detach to end the loop
392
395
  def submit(line)
393
- return detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if line.nil?
396
+ return detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if line.nil?
394
397
 
395
398
  text = line.strip
396
399
  if @question
@@ -455,7 +458,7 @@ module Samagotchi
455
458
  # @return [nil]
456
459
  def submit_delivery(route)
457
460
  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?
461
+ return @screen.commit(queue_needs_newer_worker) if Delivery.queue?(route.delivery) && !worker_queues?
459
462
 
460
463
  # To an idle session it is an ordinary prompt: no delivery to send.
461
464
  send_prompt(route.text, delivery: route.delivery)
@@ -465,10 +468,7 @@ module Samagotchi
465
468
  # Whether the attached worker runs delivery: queue (its sidecar names
466
469
  # Delivery::QUEUE_FEATURE). No sidecar to read: true, its Bridge
467
470
  # 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
471
+ def worker_queues? = @link.queues?
472
472
 
473
473
  # @return [Symbol] :detach
474
474
  def detach(line)
@@ -502,12 +502,13 @@ module Samagotchi
502
502
  # An empty session: the worker deletes it as it leaves.
503
503
  return detach(EMPTY_EXIT) if discards?(reply)
504
504
 
505
- @delete_session.call(id)
505
+ @link.delete_session
506
506
  detach("Detached; deleted session #{id}.")
507
507
  rescue SystemCallError, IOError => e
508
508
  detach("#{exit_failed_line(e.message)} Not deleted.")
509
509
  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}")
510
+ detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): " \
511
+ "#{via.command("sessions delete #{id}")}")
511
512
  end
512
513
 
513
514
  # /archive during a turn (D2): it waits for the turn's end and the
@@ -540,14 +541,14 @@ module Samagotchi
540
541
  return detach(EMPTY_EXIT) if reply.status == 200 && discards?(reply)
541
542
  return detach("#{exit_line(reply)} Not archived.") unless [200, 409].include?(reply.status)
542
543
 
543
- result = @archive_session.call(id)
544
+ result = @link.archive_session
544
545
  return detach("Detached; the session was empty, so it is discarded.") if result[:discarded].include?(id)
545
546
 
546
- detach("Detached; archived session #{id}. chi sessions list --archived finds it.")
547
+ detach("Detached; archived session #{id}. #{via.command("sessions list --archived")} finds it.")
547
548
  rescue SystemCallError, IOError => e
548
549
  detach("#{exit_failed_line(e.message)} Not archived.")
549
550
  rescue SessionManager::ArchiveRefused, SessionManager::OwnedByTUI, ArgumentError => e
550
- detach("Detached; session #{id} was not archived (#{e.message}). Re-attach with: chi --attach #{id}")
551
+ detach("Detached; session #{id} was not archived (#{e.message}). Re-attach with: #{via.attach(id)}")
551
552
  end
552
553
 
553
554
  def exit_line(reply)
@@ -557,16 +558,18 @@ module Samagotchi
557
558
  when 200
558
559
  return EMPTY_EXIT if discards?(reply)
559
560
 
560
- "Detached; the session's worker is stopping. Resume with: chi --resume #{id}"
561
+ "Detached; the session's worker is stopping. Resume with: #{via.resume(id)}"
561
562
  when 409
562
563
  reason = reply.json&.fetch("reason", nil).to_s
563
564
  "Detached; the session keeps running (#{held_words(reason, reply.json&.fetch("tasks", nil))}). " \
564
- "Re-attach with: chi --attach #{id}"
565
+ "Re-attach with: #{via.attach(id)}"
565
566
  when 0 then exit_failed_line("no reply")
566
567
  when 408 then exit_failed_line(LATE)
567
568
  else
568
569
  # 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"
570
+ if error == "not_found"
571
+ return "Detached; this worker runs an older chi and can't be stopped from here: #{via.command("sessions stop #{id}")}"
572
+ end
570
573
 
571
574
  exit_failed_line([reply.status, reply.json&.fetch("detail", nil) || error].compact.join(" "))
572
575
  end
@@ -584,7 +587,7 @@ module Samagotchi
584
587
  def discards?(reply) = reply.json&.fetch("discard", nil) == true
585
588
 
586
589
  def exit_failed_line(why)
587
- "Detached (could not ask the worker to stop: #{why}). Re-attach with: chi --attach #{@client.session_id}"
590
+ "Detached (could not ask the worker to stop: #{why}). Re-attach with: #{via.attach(@client.session_id)}"
588
591
  end
589
592
 
590
593
  # The worker says what became of it (:continue_resolved); an invalid
@@ -639,27 +642,31 @@ module Samagotchi
639
642
 
640
643
  QUEUED_NOTE = "(queued: runs after this turn)"
641
644
  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
645
  NOTHING_QUEUED = "(nothing queued: /queue TEXT queues a message for after the running turn)"
646
646
  UNQUEUE_REFUSED = { "already_started" => "(not removed: it already started)",
647
647
  "unknown_enqueued_id" => "(not removed: it is already gone)",
648
648
  "not_queued" => "(not removed: it goes in at the next step)" }.freeze
649
649
 
650
+ # /queue to a worker from before queueing, which would steer it.
651
+ def queue_needs_newer_worker
652
+ "(/queue needs a newer worker: restart it with #{via.command("sessions restart")}, then send again)"
653
+ end
654
+
655
+ def unqueue_needs_newer_worker = "(/unqueue needs a newer worker: restart it with #{via.command("sessions restart")})"
656
+
650
657
  # /unqueue: take back the newest prompt this run queued for after the
651
658
  # turn, before it runs (POST turn/dequeue). Its :turn_dequeued comes
652
659
  # to every UI; here the reply says what became of it. A worker whose
653
660
  # sidecar lacks "unqueue" is not asked.
654
661
  def unqueue_newest
655
- return @screen.commit(UNQUEUE_NEEDS_NEWER_WORKER) unless worker_unqueues?
662
+ return @screen.commit(unqueue_needs_newer_worker) unless worker_unqueues?
656
663
 
657
664
  id = @my_queued.last
658
665
  return @screen.commit(NOTHING_QUEUED) unless id
659
666
 
660
667
  reply = @client.dequeue(enqueued_id: id, client_id: @client_id)
661
668
  error = reply.json&.fetch("error", nil)
662
- return @screen.commit(UNQUEUE_NEEDS_NEWER_WORKER) if reply.status == 404 && error == "not_found"
669
+ return @screen.commit(unqueue_needs_newer_worker) if reply.status == 404 && error == "not_found"
663
670
 
664
671
  # Removed, started or gone: no longer this run's to take back.
665
672
  @my_queued.delete(id) if [200, 404, 409].include?(reply.status)
@@ -689,10 +696,7 @@ module Samagotchi
689
696
  # Whether the attached worker takes queued messages back (its sidecar
690
697
  # names SessionInbox::UNQUEUE_FEATURE). No sidecar to read: true, its Bridge
691
698
  # 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
699
+ def worker_unqueues? = @link.unqueues?
696
700
 
697
701
  # A command queued for after the running turn (command_queued with
698
702
  # waits): its line now (another client's) and a note; its command_ran
@@ -861,7 +865,7 @@ module Samagotchi
861
865
  # Read after its deadline and dropped (BridgeClient::DEADLINE_SHARE).
862
866
  return @screen.commit("could not send the prompt (#{LATE})") if detail == "deadline_passed"
863
867
  # 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"
868
+ return @screen.commit(queue_needs_newer_worker) if detail == "delivery_unavailable"
865
869
 
866
870
  @screen.commit("could not send the prompt (#{[reply.status, detail].compact.join(" ")})")
867
871
  rescue SystemCallError, IOError => e
@@ -905,7 +909,7 @@ module Samagotchi
905
909
 
906
910
  # The input ended and nothing of ours is left to wait for.
907
911
  def end_of_input
908
- detach("Detached; the session keeps running. Re-attach with: chi --attach #{@client.session_id}") if @input_ended
912
+ detach("Detached; the session keeps running. Re-attach with: #{via.attach(@client.session_id)}") if @input_ended
909
913
  return :turn_failed if @own_failed
910
914
 
911
915
  @own_empty ? :empty_answer : :detached
@@ -913,30 +917,26 @@ module Samagotchi
913
917
 
914
918
  def unanswered_question(pending)
915
919
  @unanswered = pending
916
- detach("A question waits for an answer: chi --attach #{@client.session_id}")
920
+ detach("A question waits for an answer: #{via.attach(@client.session_id)}")
917
921
  :unanswered
918
922
  end
919
923
 
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.
924
+ # The prompt's `@path` images, stored where the worker reads them
925
+ # (AttachLink#ingest_images: the session's images/ here) and sent as
926
+ # refs: the Bridge never takes a path. nil (after a line saying why)
927
+ # when one can't be.
923
928
  def attach_images(text)
924
929
  paths = ImageInput.extract(text)
925
930
  return [] if paths.empty?
926
931
 
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
932
+ @link.ingest_images(paths.map { |image| image[:path] })
932
933
  rescue ImageStore::Error => e
933
934
  @screen.commit("could not attach the image: #{e.message}")
934
935
  nil
935
936
  end
936
937
 
937
938
  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")
939
+ metrics = @link.stats_metrics
940
940
  @screen.commit(metrics ? format_session_metrics(EventRenderer.deep_symbolize_keys(metrics)) : "(no metrics: the worker did not answer)")
941
941
  end
942
942
 
@@ -955,6 +955,16 @@ module Samagotchi
955
955
  # The attached TUI's commands, for Tab.
956
956
  def slash_commands = command_registry.completions(:attached)
957
957
 
958
+ # @path and #memory candidates, as the link offers them
959
+ # (AttachLink#completions).
960
+ def build_project_path_completion_candidates(path_fragment)
961
+ @link.completions.paths ? super : []
962
+ end
963
+
964
+ def build_memory_completion_candidates(memory_fragment)
965
+ @link.completions.memories ? super : []
966
+ end
967
+
958
968
  # The session's commands as its worker's snapshot names them (its
959
969
  # plugins' too); the built-ins until one arrives, or from a worker too
960
970
  # old to name them. An unknown /foo is a prompt, as in the REPL.
@@ -1072,7 +1082,7 @@ module Samagotchi
1072
1082
  end
1073
1083
 
1074
1084
  def stale_worker(cant)
1075
- BridgeClient.stale_worker_message(@client.session_id, cant: cant)
1085
+ BridgeClient.stale_worker_message(@client.session_id, cant: cant, via: via)
1076
1086
  end
1077
1087
 
1078
1088
  # The worker rolled a failed turn back and handed its prompt back: ours
@@ -1208,7 +1218,7 @@ module Samagotchi
1208
1218
  @screen.commit("guardrails> #{snapshot[:guardrail_warning]}") if snapshot[:guardrail_warning]
1209
1219
  @screen.commit("plugins> #{snapshot[:plugin_warning]}") if snapshot[:plugin_warning]
1210
1220
  version_line = VersionLines.at_attach(worker: snapshot[:chi_version], installed: installed_version,
1211
- session_id: @client.session_id)
1221
+ session_id: @client.session_id, via: via)
1212
1222
  @screen.commit(paint(version_line, 2)) if version_line
1213
1223
  end
1214
1224
  if snapshot[:commands]
@@ -1381,13 +1391,11 @@ module Samagotchi
1381
1391
  end
1382
1392
  end
1383
1393
 
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.
1394
+ # The prompt's turn's saved tool records (AttachLink: SessionMetrics,
1395
+ # in this machine's state dir, as #attach_images): none for a prompt
1396
+ # with no turn id (an older session) or a session with no analytics.
1387
1397
  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
- []
1398
+ @link.saved_tool_records(prompt[:turn_id] || prompt["turn_id"])
1391
1399
  end
1392
1400
 
1393
1401
  # The turn's saved record when it failed with the fields its line is
@@ -1395,16 +1403,13 @@ module Samagotchi
1395
1403
  # canceled or older turn — or one saved before the failure field —
1396
1404
  # draws its answer as before.
1397
1405
  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"])
1406
+ record = @link.saved_turn_record(prompt[:turn_id] || prompt["turn_id"])
1400
1407
  return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
1401
1408
 
1402
1409
  failure = record["failure"]
1403
1410
  return nil if failure["summary"].to_s.empty? && failure["message"].to_s.empty?
1404
1411
 
1405
1412
  record
1406
- rescue Session::InvalidId
1407
- nil
1408
1413
  end
1409
1414
 
1410
1415
  # The saved record of +part+'s call (its duration and status): the
@@ -1631,14 +1636,14 @@ module Samagotchi
1631
1636
  def refresh_children
1632
1637
  return unless @status.enabled?
1633
1638
 
1634
- @status.update(children: @children_counts.call(@client.session_id))
1639
+ @status.update(children: @link.children_counts)
1635
1640
  rescue StandardError => e
1636
1641
  Log.debug(:attached, "children_counts_failed", error: e.class.name)
1637
1642
  end
1638
1643
 
1639
1644
  # The config's default model, as the worker's /model names it.
1640
1645
  def default_model_name
1641
- @default_model_name ||= ModelProfile.default_model_name
1646
+ @default_model_name ||= @link.default_model_name
1642
1647
  end
1643
1648
 
1644
1649
  def reminder_origin?(origin) = origin[:client_id].to_s.start_with?(ClientId::SYSTEM_PREFIX)
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "attach_link"
4
+ require_relative "../session"
5
+ require_relative "../bridge_client"
6
+ require_relative "../worker_sidecar"
7
+ require_relative "../delivery"
8
+ require_relative "../session_inbox"
9
+ require_relative "../image_store"
10
+ require_relative "../session_metrics"
11
+ require_relative "../children_status"
12
+ require_relative "../session_manager"
13
+ require_relative "../installed_versions"
14
+ require_relative "../model_profile"
15
+ require_relative "../parent_report"
16
+ require_relative "../chi_invocation"
17
+
18
+ module Samagotchi
19
+ class TerminalUI
20
+ # The AttachLink of a session on this machine: its worker's Bridge
21
+ # (found through the sidecar in the session folder) and the session's
22
+ # files under +state_dir+.
23
+ class LocalLink
24
+ include AttachLink
25
+
26
+ # How long /exit --delete and /archive wait for the worker to let go
27
+ # (AttachedLoop's defaults too).
28
+ DELETE_WAIT = 10
29
+
30
+ attr_reader :client
31
+
32
+ # @param client [BridgeClient] the live worker's
33
+ # @param state_dir [String] the state dir the worker was found in
34
+ # @param delete_session [#call] session id -> deletes it
35
+ # @param archive_session [#call] session id -> archives it
36
+ # (SessionManager.archive_session's result)
37
+ # @param installed_version [#call] -> the newest chi installed
38
+ # @param children_counts [#call] session id -> ChildrenStatus::Counts
39
+ def initialize(client:, state_dir: Session.default_state_dir,
40
+ delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
41
+ archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
42
+ installed_version: -> { InstalledVersions.new.newest },
43
+ children_counts: ->(id) { ChildrenStatus.counts(id, state_dir: state_dir) })
44
+ @client = client
45
+ @state_dir = state_dir
46
+ @delete_session = delete_session
47
+ @archive_session = archive_session
48
+ @installed_version = installed_version
49
+ @children_counts = children_counts
50
+ end
51
+
52
+ # The sidecar's worker, nil while none runs; gone once the session was
53
+ # stopped (no worker is coming): its stop marker in +state_dir+.
54
+ def live_worker
55
+ id = @client.session_id
56
+ return [:gone, "stopped"] if Session.stopped_marker?(id, state_dir: @state_dir)
57
+
58
+ [BridgeClient.discover(id, session_dir: session_dir, host: @client.host), nil]
59
+ end
60
+
61
+ # A new worker's Bridge, on the port its event names.
62
+ def switched(event)
63
+ @client = BridgeClient.new(session_id: @client.session_id, port: AttachLink.event_port(event), host: @client.host)
64
+ end
65
+
66
+ # No sidecar to read: true, its Bridge answers (a P0-P2 worker refuses
67
+ # a queue).
68
+ def queues? = feature?(Delivery::QUEUE_FEATURE)
69
+
70
+ # No sidecar to read: true, its Bridge answers (an older one's 404
71
+ # not_found says so).
72
+ def unqueues? = feature?(SessionInbox::UNQUEUE_FEATURE)
73
+
74
+ # The worker's stats; a worker from before the stats route answers
75
+ # only /state.
76
+ def stats_metrics
77
+ @client.get_json("stats")&.dig("metrics") || @client.get_json("state")&.dig("session_state_snapshot", "metrics")
78
+ end
79
+
80
+ # Stored in the session's images/ here (the worker's state dir).
81
+ def ingest_images(paths)
82
+ paths.map do |path|
83
+ ref = ImageStore.ingest(session_dir, path: path)
84
+ { file: ref[:file], name: ref[:name] }
85
+ end
86
+ end
87
+
88
+ # SessionMetrics, in this machine's state dir: none for a prompt with
89
+ # no turn id (an older session) or a session with no analytics.
90
+ def saved_tool_records(turn_id)
91
+ SessionMetrics.saved_tool_records(session_dir, turn_id)
92
+ rescue Session::InvalidId
93
+ []
94
+ end
95
+
96
+ def saved_turn_record(turn_id)
97
+ SessionMetrics.saved_turn_record(session_dir, turn_id)
98
+ rescue Session::InvalidId
99
+ nil
100
+ end
101
+
102
+ def children_counts = @children_counts.call(@client.session_id)
103
+
104
+ def delete_session = @delete_session.call(@client.session_id)
105
+
106
+ def archive_session = @archive_session.call(@client.session_id)
107
+
108
+ def installed_version = @installed_version.call
109
+
110
+ def default_model_name = ModelProfile.default_model_name
111
+
112
+ def parent_strategy = ParentReport.strategy_for(@client.session_id, state_dir: @state_dir)
113
+
114
+ def completions = Completions::ALL
115
+
116
+ def reconnect_delays = BridgeClient::EventStream::REDISCOVER_DELAYS
117
+
118
+ def via = ChiInvocation::HERE
119
+
120
+ private
121
+
122
+ def session_dir = Session.session_dir(@client.session_id, state_dir: @state_dir)
123
+
124
+ def feature?(name)
125
+ sidecar = WorkerSidecar.read(session_dir)
126
+ sidecar.nil? || sidecar.features.include?(name)
127
+ end
128
+ end
129
+ end
130
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "../installed_versions"
4
4
  require_relative "../version"
5
+ require_relative "../chi_invocation"
5
6
 
6
7
  module Samagotchi
7
8
  class TerminalUI
@@ -15,8 +16,9 @@ module Samagotchi
15
16
  # @param worker [String, nil] the worker's chi_version (nil: a worker
16
17
  # from before it said so; nothing is said then)
17
18
  # @param installed [String, nil] InstalledVersions#newest
19
+ # @param via [ChiInvocation] how the commands in it are spelled
18
20
  # @return [String, nil] one "chi> ..." line, or nil when all is current
19
- def at_attach(worker:, installed:, session_id:, terminal: VERSION)
21
+ def at_attach(worker:, installed:, session_id:, terminal: VERSION, via: ChiInvocation::HERE)
20
22
  return nil if worker.to_s.empty?
21
23
 
22
24
  newest = [installed, terminal].select { |v| Gem::Version.correct?(v.to_s) && !v.to_s.empty? }
@@ -26,8 +28,8 @@ module Samagotchi
26
28
  return nil unless worker_old || terminal_old
27
29
 
28
30
  id = session_id.to_s[0, 8]
29
- restart = "chi sessions restart #{id}"
30
- attach = "/detach, then chi --attach #{id}"
31
+ restart = via.command("sessions restart #{id}")
32
+ attach = "/detach, then #{via.attach(id)}"
31
33
  if worker_old && terminal_old
32
34
  "chi> chi #{newest} is installed; this session's worker runs #{worker} and this terminal #{terminal}. " \
33
35
  "#{restart} moves the worker; #{attach} for the terminal."
@@ -890,8 +890,8 @@ module Samagotchi
890
890
  show_command_result(command)
891
891
  refresh_status_row(clear_unknown_context: true) if command.changed.include?(:model)
892
892
  refresh_status_row if command.changed.include?(:title)
893
- # /recap on|off|default doesn't save itself (a worker saves a changed command).
894
- save_session(session) if command.changed.include?(:recap)
893
+ # /recap and /title don't save themselves (a worker saves a changed command).
894
+ save_session(session) if command.changed.intersect?(%i[recap title])
895
895
  persist_recent_history(input) if command.shell
896
896
  return
897
897
  end
@@ -3,12 +3,13 @@
3
3
  module Samagotchi
4
4
  # /title, the session's own name (Session#title): with no argument, the
5
5
  # title and what shows without one; `/title TEXT` names the session
6
- # (whitespace collapsed, cut at 80 characters as a preview is), saved at
7
- # once; `/title --clear` drops it, and the lists show the first message
8
- # again. Anyone who may send the session a command may name it (a parent
9
- # agent its delegates too). It runs mid-turn as well (anytime): the save
10
- # takes the session's lock and writes a running turn's checkpoint, as a
11
- # turn's own saves do.
6
+ # (whitespace collapsed, cut at 80 characters as a preview is);
7
+ # `/title --clear` drops it, and the lists show the first message again.
8
+ # Anyone who may send the session a command may name it (a parent agent
9
+ # its delegates too). It doesn't save: its host saves a changed result at
10
+ # once (Worker, the REPL). It runs mid-turn as well (anytime): the
11
+ # worker's save takes the session's lock and writes a running turn's
12
+ # checkpoint, as a turn's own saves do.
12
13
  class TitleCommand
13
14
  NAME = "/title"
14
15
  CLEAR = "--clear"
@@ -17,10 +18,8 @@ module Samagotchi
17
18
  LIMIT = 80
18
19
 
19
20
  # @param engine [Engine] #session
20
- # @param save [#call] saves the session after a change
21
- def initialize(engine:, save:)
21
+ def initialize(engine:)
22
22
  @engine = engine
23
- @save = save
24
23
  end
25
24
 
26
25
  # @param args [String] what follows /title
@@ -42,9 +41,8 @@ module Samagotchi
42
41
  session.title = title
43
42
  # The command itself as the session's first line (chi send --new
44
43
  # "/title X", a web first message) became its preview: it goes, and
45
- # the save derives the preview from the first user message again.
44
+ # the host's save derives the preview from the first user message again.
46
45
  session.first_preview = "" if session.first_preview.to_s.start_with?(NAME)
47
- @save.call(session)
48
46
  [listing(session), true, :ok]
49
47
  end
50
48
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.102.0"
4
+ VERSION = "0.103.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end