samagotchi 0.18.0 → 0.19.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +119 -1
  3. data/README.md +4 -3
  4. data/bin/chi +5 -2
  5. data/docs/architecture.md +15 -2
  6. data/docs/cli.md +41 -9
  7. data/docs/configuration.md +8 -4
  8. data/docs/guardrails.md +8 -0
  9. data/docs/memory.md +4 -0
  10. data/docs/sessions.md +6 -4
  11. data/lib/samagotchi/bridge.rb +25 -9
  12. data/lib/samagotchi/bridge_client/event_stream.rb +37 -1
  13. data/lib/samagotchi/bridge_client.rb +9 -5
  14. data/lib/samagotchi/bundle_command.rb +2 -1
  15. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +7 -4
  16. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/system/memory_guide.md +1 -1
  19. data/lib/samagotchi/client.rb +58 -10
  20. data/lib/samagotchi/engine.rb +35 -11
  21. data/lib/samagotchi/guardrail_wiring.rb +64 -5
  22. data/lib/samagotchi/guardrails/approval.rb +10 -4
  23. data/lib/samagotchi/guardrails/approvals.rb +60 -12
  24. data/lib/samagotchi/host_registry.rb +22 -4
  25. data/lib/samagotchi/idle_scheduler.rb +1 -1
  26. data/lib/samagotchi/installed_versions.rb +91 -0
  27. data/lib/samagotchi/live_versions.rb +5 -3
  28. data/lib/samagotchi/llm/http.rb +9 -3
  29. data/lib/samagotchi/memory_bundle/builder.rb +24 -1
  30. data/lib/samagotchi/memory_bundle/index_sync.rb +1 -9
  31. data/lib/samagotchi/memory_bundle/installer.rb +33 -0
  32. data/lib/samagotchi/memory_bundle/status.rb +5 -1
  33. data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
  34. data/lib/samagotchi/model_overlay.rb +39 -0
  35. data/lib/samagotchi/model_profile.rb +6 -2
  36. data/lib/samagotchi/output_formatter.rb +9 -4
  37. data/lib/samagotchi/relay_desk.rb +9 -0
  38. data/lib/samagotchi/served_model.rb +14 -4
  39. data/lib/samagotchi/session_manager.rb +166 -11
  40. data/lib/samagotchi/session_metrics.rb +36 -13
  41. data/lib/samagotchi/sessions_command.rb +43 -4
  42. data/lib/samagotchi/system_prompt.rb +10 -1
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +56 -6
  44. data/lib/samagotchi/terminal_ui/image_input.rb +11 -4
  45. data/lib/samagotchi/terminal_ui/one_shot_sink.rb +63 -0
  46. data/lib/samagotchi/terminal_ui/version_lines.rb +48 -0
  47. data/lib/samagotchi/terminal_ui.rb +65 -16
  48. data/lib/samagotchi/tool_declarations.rb +2 -2
  49. data/lib/samagotchi/tools/builtins.rb +21 -5
  50. data/lib/samagotchi/tools/execute.rb +97 -19
  51. data/lib/samagotchi/turn_note.rb +3 -2
  52. data/lib/samagotchi/update_command.rb +33 -12
  53. data/lib/samagotchi/version.rb +1 -1
  54. data/lib/samagotchi/web/app.rb +64 -11
  55. data/lib/samagotchi/web/public/app.js +102 -29
  56. data/lib/samagotchi/web/public/data.js +6 -0
  57. data/lib/samagotchi/web/public/format.js +4 -2
  58. data/lib/samagotchi/web/public/index.html +11 -0
  59. data/lib/samagotchi/web/public/question_card.js +12 -0
  60. data/lib/samagotchi/web/public/question_cards.js +17 -11
  61. data/lib/samagotchi/web/public/scope.js +15 -6
  62. data/lib/samagotchi/web/public/session_state.js +4 -0
  63. data/lib/samagotchi/web/public/turn_events.js +107 -3
  64. data/lib/samagotchi/web/public/update_notice.js +87 -14
  65. data/lib/samagotchi/web/server.rb +28 -1
  66. data/lib/samagotchi/web/session_hub.rb +30 -3
  67. data/lib/samagotchi/web/session_summary.rb +5 -0
  68. data/lib/samagotchi/worker.rb +25 -8
  69. data/lib/samagotchi/worker_idle_exit.rb +31 -2
  70. data/lib/samagotchi/worker_sidecar.rb +7 -5
  71. metadata +4 -1
@@ -19,7 +19,9 @@ require_relative "bridge_client"
19
19
  require_relative "worker_sidecar"
20
20
  require_relative "log"
21
21
  require_relative "log_path"
22
+ require_relative "model_profile"
22
23
  require_relative "installed_gem"
24
+ require_relative "installed_versions"
23
25
  require_relative "recap_store"
24
26
  require_relative "archive_store"
25
27
  require_relative "image_store"
@@ -191,6 +193,7 @@ module Samagotchi
191
193
  # A worker gets no CLI args: --model, --thinking, --log-level and the
192
194
  # other flags this chi was started with.
193
195
  child_env.merge!(Config.cli_env)
196
+ child_env[ModelProfile::MODEL_FROM_CLI_ENV] = "1" if child_env.key?(ModelProfile::MODEL_ENV)
194
197
  # And the spawner's debug log, absolute: a relative log.file would
195
198
  # otherwise land in the worker's (the session's) directory.
196
199
  log_path = begin LogPath.resolve rescue nil end
@@ -204,10 +207,25 @@ module Samagotchi
204
207
  # key mapped to nil. config.yml no longer reads them; this keeps an
205
208
  # older or future env-exposed one out as well.
206
209
  ENV.each_key { |key| child_env[key] = nil if key.start_with?(GUARDRAILS_ENV_PREFIX) }
210
+ # A smoke's fake "newest installed" is for this process's notices only.
211
+ child_env[InstalledVersions::ENV_KEY] = nil
212
+ # A gem-mode worker activates the newest installed chi itself: under
213
+ # `bundle exec` (a Gemfile that has samagotchi) Bundler's RUBYOPT would
214
+ # hold it to the bundle's version, and its badge would never clear.
215
+ child_env.merge!(unbundled_env_changes) if InstalledGem.spec
207
216
  opts[:env] = child_env unless child_env.empty?
208
217
  opts
209
218
  end
210
219
 
220
+ # What Bundler changed in ENV (RUBYOPT, BUNDLE_*), mapped back to the
221
+ # values from before it (nil: unset); {} outside Bundler.
222
+ private_class_method def self.unbundled_env_changes(env = ENV.to_h)
223
+ return {} unless defined?(::Bundler) && ::Bundler.respond_to?(:unbundled_env)
224
+
225
+ clean = ::Bundler.unbundled_env
226
+ (env.keys | clean.keys).each_with_object({}) { |key, changes| changes[key] = clean[key] unless clean[key] == env[key] }
227
+ end
228
+
211
229
  GUARDRAILS_ENV_PREFIX = "SAMAGOTCHI_GUARDRAILS_"
212
230
 
213
231
  # List all sessions, reading status from persisted session.json files.
@@ -409,6 +427,10 @@ module Samagotchi
409
427
  # status is turn state, not liveness: a new worker runs no turn yet,
410
428
  # and must not find the session stopped (it would exit): neither the
411
429
  # marker nor a status field from before it.
430
+ # One this process just spawned may not hold the lock yet (a slow
431
+ # start, deliver_turn's second resume): it would only lose it.
432
+ return session if still_starting?(session.id, state_dir: sd)
433
+
412
434
  Session.clear_stopped(session.id, state_dir: sd)
413
435
  session.status = Session::STATUS_IDLE
414
436
  session.save(state_dir: sd)
@@ -416,6 +438,36 @@ module Samagotchi
416
438
  session
417
439
  end
418
440
 
441
+ # How long after a spawn a resume trusts that worker to take the
442
+ # session, while it runs (and the session wasn't stopped since).
443
+ WORKER_START_GRACE = 30.0
444
+
445
+ @spawns = {}
446
+ @spawns_mutex = Mutex.new
447
+
448
+ # A worker this process spawned for the session within
449
+ # WORKER_START_GRACE is still running, and no stop came since.
450
+ private_class_method def self.still_starting?(session_id, state_dir:)
451
+ return false if Session.stopped_marker?(session_id, state_dir: state_dir)
452
+
453
+ at, waiter = @spawns_mutex.synchronize { @spawns[[state_dir, session_id]] }
454
+ return false unless at && waiter.respond_to?(:alive?) && waiter.alive?
455
+
456
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - at < WORKER_START_GRACE
457
+ end
458
+
459
+ private_class_method def self.note_spawn(session_id, state_dir:, waiter:)
460
+ @spawns_mutex.synchronize do
461
+ @spawns.delete_if { |_, (_, w)| !(w.respond_to?(:alive?) && w.alive?) }
462
+ @spawns[[state_dir, session_id]] = [Process.clock_gettime(Process::CLOCK_MONOTONIC), waiter]
463
+ end
464
+ end
465
+
466
+ # Forget the spawns #still_starting? remembers (specs).
467
+ def self.forget_spawns
468
+ @spawns_mutex.synchronize { @spawns.clear }
469
+ end
470
+
419
471
  # Read any new output files from a session directory.
420
472
  # Optionally filters to only files newer than `since_time`.
421
473
  def self.read_responses(session_id, since_time: nil, state_dir: nil)
@@ -423,6 +475,85 @@ module Samagotchi
423
475
  since_time: since_time)
424
476
  end
425
477
 
478
+ # A restart that can't happen now. reason: :not_running (no live worker:
479
+ # the next prompt starts one on the newest chi anyway), :unsupported (a
480
+ # worker from before restarts, running +version+: stop it instead), a
481
+ # WorkerIdleExit#hold_for_restart hold (:turn_running, :reminders, ...),
482
+ # or :failed (the worker answered something else).
483
+ class RestartRefused < StandardError
484
+ attr_reader :session_id, :reason, :version
485
+
486
+ def initialize(session_id, reason, version: nil)
487
+ @session_id = session_id
488
+ @reason = reason.to_sym
489
+ @version = version
490
+ super(RestartRefused.words(session_id, @reason, version))
491
+ end
492
+
493
+ HOLDS = {
494
+ starting: "its worker is still starting",
495
+ turn_running: "a turn is running",
496
+ input_queued: "a prompt is queued",
497
+ continue_offered: "the step-limit question waits for an answer",
498
+ question_pending: "a question or approval waits for an answer",
499
+ reminders: "it has reminders, which a restart would drop",
500
+ command_running: "a /btw (or another command) is still running",
501
+ relays_open: "a delegate's approval relayed here is open or just answered",
502
+ background_tasks: "background tasks it started are still running"
503
+ }.freeze
504
+
505
+ # What the CLI and the web say.
506
+ def self.words(session_id, reason, version = nil)
507
+ case reason
508
+ when :not_running
509
+ "session #{session_id} has no running worker; the next prompt starts one on the newest chi"
510
+ when :unsupported
511
+ "session #{session_id}'s worker runs chi #{version || "(older)"}, which can't restart: " \
512
+ "chi sessions stop #{session_id}, then any prompt starts it on the newest chi"
513
+ when :failed then "session #{session_id}'s worker didn't take the restart"
514
+ else "not now: #{HOLDS.fetch(reason, reason.to_s.tr("_", " "))}"
515
+ end
516
+ end
517
+ end
518
+
519
+ # What a restart did: the worker's version before and after (nil when
520
+ # the new worker hadn't published its Bridge within the wait).
521
+ Restarted = Struct.new(:session_id, :from_version, :version, keyword_init: true)
522
+
523
+ RESTART_WAIT = 10.0
524
+
525
+ # Hand a session to a new worker on the newest chi installed: ask its
526
+ # worker (POST /exit restart: true), which leaves only when nothing would
527
+ # be lost and starts its successor (Worker#exit_request), then wait up to
528
+ # +wait+ seconds for the successor's Bridge. Web tabs and attached
529
+ # terminals reconnect to it.
530
+ # @return [Restarted]
531
+ # @raise [OwnedByTUI] a chi REPL owns the session
532
+ # @raise [RestartRefused]
533
+ # @raise [ArgumentError] no such session
534
+ def self.restart_session(session_id, state_dir: nil, wait: RESTART_WAIT, client_id: "cli:restart")
535
+ sd = state_dir || Session.default_state_dir
536
+ Session.load(session_id, state_dir: sd) # ArgumentError for none
537
+ owner = refuse_tui!(session_id, state_dir: sd)
538
+ dir = Session.session_dir(session_id, state_dir: sd)
539
+ sidecar = owner&.worker? && WorkerSidecar.live(dir, unlink: false)
540
+ raise RestartRefused.new(session_id, :not_running) unless sidecar
541
+ unless sidecar.features.include?("restart")
542
+ raise RestartRefused.new(session_id, :unsupported, version: sidecar.version)
543
+ end
544
+
545
+ reply = BridgeClient.new(session_id: session_id, port: sidecar.port).request_exit(client_id: client_id, restart: true)
546
+ raise RestartRefused.new(session_id, reply.json&.fetch("reason", nil) || :failed) if reply.status == 409
547
+ raise RestartRefused.new(session_id, :failed) unless reply.status == 200
548
+
549
+ Log.info(:worker, "restart", sid: session_id, from: sidecar.version, by: client_id)
550
+ fresh = BridgeClient.poll(wait, interval: 0.1) do
551
+ found = WorkerSidecar.live(dir, unlink: false)
552
+ found if found && found.started_at != sidecar.started_at
553
+ end
554
+ Restarted.new(session_id: session_id, from_version: sidecar.version, version: fresh&.version)
555
+ end
556
+
426
557
  # Stop a session by sending TERM to its process.
427
558
  # With +wait+ (seconds), also wait for the owner to let go of the session,
428
559
  # so a resume right after spawns a fresh worker instead of finding the
@@ -638,7 +769,7 @@ module Samagotchi
638
769
  # @param poll_interval [Numeric, nil] seconds between the loop's fallback
639
770
  # ticks (nil: Worker::FALLBACK_TICK_SECONDS); queued turns wake it at once
640
771
  # A stopped session's worker exits 0 and a crashed one 1, there.
641
- # @return [Symbol] :idle_exit or :exit_requested
772
+ # @return [Symbol] :idle_exit, :exit_requested or :restart
642
773
  def self.run_session_loop(session_id, state_dir: nil, owner_wait: OwnerLock::DEFAULT_WAIT,
643
774
  idle_exit_minutes: nil, poll_interval: nil)
644
775
  # A worker inherits chi's locale (LC_ALL=C too): read files as UTF-8.
@@ -652,7 +783,10 @@ module Samagotchi
652
783
  # Kept in a class ivar so the lock's File lives as long as the worker.
653
784
  @owner_lock = OwnerLock.acquire(session_dir, kind: "worker", wait: owner_wait)
654
785
  exit(0) unless @owner_lock
655
- Log.info(:worker, "start", cwd: Dir.pwd)
786
+ # Set by the boot string when the newest installed chi didn't load.
787
+ fallback = ENV.delete(BOOT_FALLBACK_ENV)
788
+ Log.info(:worker, "start", cwd: Dir.pwd, version: VERSION)
789
+ Log.warn(:worker, "boot_fallback", version: VERSION, error: fallback) if fallback
656
790
  worker = Worker.new(session_id: session_id, state_dir: sd, session_dir: session_dir,
657
791
  idle_exit_minutes: idle_exit_minutes, poll_interval: poll_interval)
658
792
  result = begin
@@ -672,7 +806,10 @@ module Samagotchi
672
806
  # Only after the release: a writer that still saw this worker as the
673
807
  # owner may have queued input since the last check. Either it finds no
674
808
  # owner after its write and wakes one, or this finds its input.
675
- if %i[idle_exit exit_requested].include?(result) && !SessionInbox.find_new_input_files(session_dir).empty?
809
+ # A restart starts its successor here, on the newest chi installed
810
+ # (worker_command), once this one's lock is free.
811
+ if result == :restart ||
812
+ (%i[idle_exit exit_requested].include?(result) && !SessionInbox.find_new_input_files(session_dir).empty?)
676
813
  resume_session(session_id, state_dir: sd)
677
814
  elsif worker.discard?
678
815
  discard_left_session(session_id, state_dir: sd, default_model: worker.default_model)
@@ -707,16 +844,31 @@ module Samagotchi
707
844
  session.save(state_dir: state_dir)
708
845
  end
709
846
 
710
- # The worker's command line: this chi's lib/ and ruby. Run from an
711
- # installed gem, the worker activates that gem first, so its dependencies
712
- # resolve as the gemspec pins them (reline ~> 0.6.3) rather than to the
713
- # newest installed version. A source checkout (bin/chi, bundle exec)
714
- # keeps the plain -I lib.
847
+ # The worker's command line. Run from an installed gem, the worker
848
+ # activates the newest installed samagotchi (`>= 0.a`, prereleases
849
+ # count), whoever spawns it: an old chi web, chi send or --attach then
850
+ # starts new workers on the newest chi rather than on its own. Activating
851
+ # the gem also makes its dependencies resolve as its gemspec pins them
852
+ # (reline ~> 0.6.3). Should that fail (a broken newest install), the
853
+ # worker falls back to the spawner's own version and logs why.
854
+ #
855
+ # A source checkout (bin/chi, bundle exec; InstalledGem.spec is nil
856
+ # there) keeps the plain -I lib: a worktree's bin/chi spawns its own code.
857
+ #
858
+ # CROSS-VERSION CONTRACT: this boot string, run_session_loop(id,
859
+ # state_dir:), the env keys spawn_options sets and the input/ file
860
+ # format are read by a NEWER chi than the one writing them. Change them
861
+ # only additively (spec/worker_contract_spec.rb).
862
+ BOOT_FALLBACK_ENV = "SAMAGOTCHI_BOOT_FALLBACK"
863
+
715
864
  def self.worker_command(session_id, state_dir:, gem_spec: InstalledGem.spec)
716
865
  boot = "require 'samagotchi/session_manager'; " \
717
866
  "Samagotchi::SessionManager.run_session_loop('#{session_id}', state_dir: #{state_dir.inspect})"
718
- boot = "gem 'samagotchi', '= #{gem_spec.version}'; #{boot}" if gem_spec
719
- [RbConfig.ruby, "-I", File.expand_path("..", __dir__), "-e", boot]
867
+ return [RbConfig.ruby, "-I", File.expand_path("..", __dir__), "-e", boot] unless gem_spec
868
+
869
+ activate = "begin; gem 'samagotchi', '>= 0.a'; rescue LoadError => e; " \
870
+ "ENV['#{BOOT_FALLBACK_ENV}'] = e.message; gem 'samagotchi', '= #{gem_spec.version}'; end"
871
+ [RbConfig.ruby, "-e", "#{activate}; #{boot}"]
720
872
  end
721
873
 
722
874
  private_class_method def self.spawn_worker_for_session(session, state_dir:)
@@ -729,6 +881,8 @@ module Samagotchi
729
881
  env = opts.delete(:env)
730
882
  command = worker_command(session.id, state_dir: state_dir)
731
883
  pid = env ? Process.spawn(env, *command, **opts) : Process.spawn(*command, **opts)
884
+ # Reaped when it exits: a long-lived spawner (chi web) keeps no zombies.
885
+ note_spawn(session.id, state_dir: state_dir, waiter: Process.detach(pid))
732
886
  Log.info(:worker, "spawn", sid: session.id, child_pid: pid)
733
887
  pid
734
888
  end
@@ -831,7 +985,8 @@ module Samagotchi
831
985
  raise OwnedByTUI, session_id
832
986
  end
833
987
  # A worker that idle-exited since the resume never reads it either:
834
- # wake a new one. (The exiting worker also looks for input it left.)
988
+ # wake a new one. (The exiting worker also looks for input it left;
989
+ # one the first resume spawned that is still starting is left be.)
835
990
  manager.resume_session(session_id, state_dir: state_dir) if owner.nil?
836
991
  { status: :accepted, ack: { status: "accepted", enqueued_id: enqueued_id, session_id: session_id } }
837
992
  end
@@ -296,7 +296,8 @@ module Samagotchi
296
296
  retries: totals[:retries] + (turn&.retries || 0),
297
297
  started_at: @started_at,
298
298
  last_activity_at: @last_activity_at,
299
- session_duration_ms: elapsed_ms(@session_started_monotonic),
299
+ # From the session's first start (an earlier process's too), to now.
300
+ session_duration_ms: ms_since(@started_at),
300
301
  **records_view(records_mode),
301
302
  active_turn: active_turn_snapshot,
302
303
  active_tools: active_tool_snapshots
@@ -327,7 +328,6 @@ module Samagotchi
327
328
  load_persisted
328
329
  end
329
330
  @started_at ||= now.iso8601(3)
330
- @session_started_monotonic ||= monotonic_time
331
331
  @turn = TurnState.new(
332
332
  session_id: @session_id,
333
333
  iteration_count: 0,
@@ -364,6 +364,10 @@ module Samagotchi
364
364
  # A turn that failed mid-stream never saw its generation end:
365
365
  # what it got to still counts.
366
366
  close_generation if @turn.gen_open
367
+ # A call still running when the turn ends (a cancel, a failure)
368
+ # never completes: its record says so, as the count already has it.
369
+ @turn.tool_calls_by_id.each_value { |active| finish_tool_record(active, "canceled") }
370
+ @turn.tool_calls_by_id.clear
367
371
  finished_at = now
368
372
  record = {
369
373
  id: @turn.id,
@@ -486,20 +490,24 @@ module Samagotchi
486
490
  active = @turn.tool_calls_by_id.delete(key)
487
491
  return unless active
488
492
 
489
- finished_at = now
490
- record = active.slice(
491
- :id, :turn_id, :iteration, :call_index, :tool, :started_at
492
- ).merge(
493
- status: status.empty? ? "ok" : status,
494
- completed_at: finished_at.iso8601(3),
495
- # Less a guardrail approval wait (ToolRunner's waited_ms).
496
- duration_ms: [elapsed_ms(active[:started_monotonic]) - event[:waited_ms].to_i, 0].max
497
- )
498
- @tool_records << record
499
- count_tool(record)
493
+ finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
500
494
  end
501
495
  end
502
496
 
497
+ # Caller holds the mutex.
498
+ def finish_tool_record(active, status, waited_ms: 0)
499
+ record = active.slice(
500
+ :id, :turn_id, :iteration, :call_index, :tool, :started_at
501
+ ).merge(
502
+ status: status,
503
+ completed_at: now.iso8601(3),
504
+ # Less a guardrail approval wait (ToolRunner's waited_ms).
505
+ duration_ms: [elapsed_ms(active[:started_monotonic]) - waited_ms, 0].max
506
+ )
507
+ @tool_records << record
508
+ count_tool(record)
509
+ end
510
+
503
511
  # Accumulate token counts from a streamed generation_chunk payload.
504
512
  # Server-first: prefer real timings/usage. Prompt and completion tokens
505
513
  # are tracked as a per-generation MAX (the server's counts are cumulative
@@ -558,6 +566,15 @@ module Samagotchi
558
566
  [((monotonic_time - started) * 1000.0).round, 0].max
559
567
  end
560
568
 
569
+ # Wall-clock ms from an ISO 8601 stamp to now (0 without one).
570
+ def ms_since(timestamp)
571
+ return 0 unless timestamp
572
+
573
+ [((now - Time.iso8601(timestamp.to_s)) * 1000.0).round, 0].max
574
+ rescue ArgumentError
575
+ 0
576
+ end
577
+
561
578
  def tool_key(iteration, call_index)
562
579
  "#{iteration}:#{call_index}"
563
580
  end
@@ -609,6 +626,12 @@ module Samagotchi
609
626
  @tool_records.each { |record| count_tool(record) }
610
627
  @started_at = earliest_timestamp(prior["started_at"], @started_at)
611
628
  @last_activity_at ||= prior["last_activity_at"]
629
+ # The served model last reported, with the name asked for then (the
630
+ # Engine shows it only while that name is still the one asked for).
631
+ if @served_model.nil? && prior["served_model"]
632
+ @served_model = prior["served_model"]
633
+ @served_model_for = prior["served_model_for"]
634
+ end
612
635
  # The window last seen, until this process's first generation reports.
613
636
  window = prior["context"].is_a?(Hash) ? prior["context"] : {}
614
637
  if window["window_tokens"] && @context_window_tokens.nil?
@@ -13,7 +13,7 @@ require_relative "cli/command"
13
13
  require_relative "cli/flags"
14
14
 
15
15
  module Samagotchi
16
- # `chi sessions`: list, stop, archive/unarchive, delete, prune and clean
16
+ # `chi sessions`: list, stop, restart, archive/unarchive, delete, prune and clean
17
17
  # sessions from the shell (bin/chi dispatches here before OptionParser;
18
18
  # the subcommands have their own flags). Not SessionCommands, the REPL's
19
19
  # slash commands for sessions (session_commands.rb).
@@ -21,7 +21,7 @@ module Samagotchi
21
21
  include CLI::Command
22
22
 
23
23
  USAGE = <<~TEXT
24
- Usage: chi sessions <list|stop|archive|unarchive|delete|prune|clean> [options]
24
+ Usage: chi sessions <list|stop|restart|archive|unarchive|delete|prune|clean> [options]
25
25
  list [--sort updated_at|created_at] [--order desc|asc] [--limit N]
26
26
  [--live] [--cwd PATH] [--format text|json|tsv] [--archived]
27
27
  --live: sessions a worker runs now (the ones chi note reaches), 10 unless --limit
@@ -29,6 +29,7 @@ module Samagotchi
29
29
  [--scope=all]: every project's sessions; by default only this git project's (all outside a repo)
30
30
  --archived: archived sessions too (marked [archived]; json: archived: true)
31
31
  stop ID... # stop each session's worker (IDs or unique prefixes); chi --resume ID then starts a fresh one
32
+ restart ID... # hand each session to a new worker on the newest chi installed; refused while a turn, question or reminder would be lost
32
33
  archive ID... # hide sessions (and their delegates) from every list and keep them for good; unarchive ID... brings them back
33
34
  delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first
34
35
  prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
@@ -37,7 +38,8 @@ module Samagotchi
37
38
  TEXT
38
39
 
39
40
  STOP_USAGE = "Usage: chi sessions stop ID...\n"
40
- SUBCOMMANDS = %w[list stop archive unarchive delete prune clean].freeze
41
+ RESTART_USAGE = "Usage: chi sessions restart ID...\n"
42
+ SUBCOMMANDS = %w[list stop restart archive unarchive delete prune clean].freeze
41
43
 
42
44
  # Each subcommand's flags ("--flag V" or "--flag=V" for a value): an
43
45
  # unknown flag, a value flag with nothing after it or an argument where
@@ -50,6 +52,7 @@ module Samagotchi
50
52
  f.switch "--archived", key: :include_archived
51
53
  end,
52
54
  "stop" => CLI::Flags.new,
55
+ "restart" => CLI::Flags.new,
53
56
  "prune" => CLI::Flags.new(args: false) do |f|
54
57
  %w[--days --keep --keep-status].each { |name| f.value name }
55
58
  f.switch "--dry-run"
@@ -94,6 +97,7 @@ module Samagotchi
94
97
  case @sub
95
98
  when "list" then list
96
99
  when "stop" then stop(parsed.args)
100
+ when "restart" then restart(parsed.args)
97
101
  else prune(@sub)
98
102
  end
99
103
  end
@@ -101,7 +105,7 @@ module Samagotchi
101
105
  private
102
106
 
103
107
  def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi sessions #{@sub}" : "chi sessions"
104
- def usage_text = @sub == "stop" ? STOP_USAGE : USAGE
108
+ def usage_text = { "stop" => STOP_USAGE, "restart" => RESTART_USAGE }.fetch(@sub, USAGE)
105
109
 
106
110
  # quirk: a number that isn't one is 0 (--days=abc turns the age limit off)
107
111
  def integers(options)
@@ -257,6 +261,41 @@ module Samagotchi
257
261
  ok.all? ? 0 : 1
258
262
  end
259
263
 
264
+ # Each session in turn: its worker hands over to a new one on the newest
265
+ # chi (SessionManager.restart_session), or says why not.
266
+ def restart(ids)
267
+ return usage_error("give session ids") if ids.empty?
268
+
269
+ ok = ids.uniq.map do |given|
270
+ begin
271
+ id = Samagotchi::Session.resolve_id(given)
272
+ result = Samagotchi::SessionManager.restart_session(id)
273
+ rescue Samagotchi::SessionManager::OwnedByTUI
274
+ @stdout.flush
275
+ @stderr.puts "session #{id} is open in a chi REPL; close it there first"
276
+ next false
277
+ rescue Samagotchi::SessionManager::RestartRefused => e
278
+ @stdout.flush
279
+ @stderr.puts e.reason == :not_running || e.reason == :unsupported ? e.message : "session #{id}: #{e.message}"
280
+ next false
281
+ rescue ArgumentError => e
282
+ @stdout.flush
283
+ @stderr.puts e.message
284
+ next false
285
+ end
286
+ @stdout.puts restarted_line(result)
287
+ true
288
+ end
289
+ ok.all? ? 0 : 1
290
+ end
291
+
292
+ def restarted_line(result)
293
+ return "Restarting session #{result.session_id}; its new worker is still starting." unless result.version
294
+
295
+ from = result.from_version && result.from_version != result.version ? " (was #{result.from_version})" : ""
296
+ "Restarted session #{result.session_id} on chi #{result.version}#{from}."
297
+ end
298
+
260
299
  def prune(sub)
261
300
  days, keep, keep_status, dry_run, test_only = @opts.values_at(:days, :keep, :keep_status, :dry_run, :test_only)
262
301
  test_only = true if sub == "clean" && !@opts[:all]
@@ -310,6 +310,15 @@ module Samagotchi
310
310
  end
311
311
  end
312
312
 
313
+ # Once per name for this prompt (the Engine's, one per process): a
314
+ # rebuild (another level, a /model switch) doesn't repeat it.
315
+ def warn_preload_failed(name, echo)
316
+ @warned_preloads ||= Set.new
317
+ return unless @warned_preloads.add?(name)
318
+
319
+ Log.warn(:memory, "preload_failed", echo: echo, memory: name)
320
+ end
321
+
313
322
  def explicit_memory_section
314
323
  return nil if @requested_memories.empty?
315
324
 
@@ -322,7 +331,7 @@ module Samagotchi
322
331
  body = Tools::MemoryRead.call(actual_name, scope: scope, **overlay_keys)
323
332
  if body.start_with?("Error:")
324
333
  source = Array(@config_memories).include?(raw) ? "memory '#{name}' (from config memories:)" : "--memory '#{name}'"
325
- Log.warn(:memory, "preload_failed", echo: "Warning: #{source} could not be loaded (#{body})", memory: name)
334
+ warn_preload_failed(name, "Warning: #{source} could not be loaded (#{body})")
326
335
  next
327
336
  end
328
337
  # Record activated names so the UI can echo them in the sticky
@@ -11,6 +11,7 @@ require_relative "image_input"
11
11
  require_relative "line_reader"
12
12
  require_relative "question_prompt"
13
13
  require_relative "reline_seam"
14
+ require_relative "version_lines"
14
15
  require_relative "../bridge/turn_accumulator"
15
16
  require_relative "../bridge_client"
16
17
  require_relative "../log"
@@ -92,8 +93,10 @@ module Samagotchi
92
93
  default_input: false, wait_at_eof: false, parent_answers: false,
93
94
  clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
94
95
  delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: DELETE_WAIT) },
95
- archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) })
96
+ archive_session: ->(id) { SessionManager.archive_session(id, wait: DELETE_WAIT) },
97
+ installed_version: -> { InstalledVersions.new.newest })
96
98
  @client = client
99
+ @installed_version = installed_version
97
100
  @delete_session = delete_session
98
101
  @archive_session = archive_session
99
102
  @screen = screen
@@ -108,6 +111,8 @@ module Samagotchi
108
111
  @running = false
109
112
  @joined_mid_turn = false
110
113
  @attached = false
114
+ # The stream moved to a new worker; its snapshot resyncs (#switch_worker).
115
+ @worker_changed = false
111
116
  @early_lines = []
112
117
  @question = nil
113
118
  @answered_ids = Set.new
@@ -158,7 +163,12 @@ module Samagotchi
158
163
  RelineSeam.interrupt_handler = method(:note_interrupted_line)
159
164
  # Tagged, so the worker doesn't count this stream as another UI
160
165
  # when this UI asks it to exit.
161
- stream = @client.follow(client_id: @client_id) { |event| queue << [:event, event] }
166
+ # A new worker (a restart) is followed, not lost: its sidecar is
167
+ # looked for whenever the stream drops.
168
+ stream = @client.follow(client_id: @client_id, rediscover: method(:live_worker),
169
+ reconnect_delays: BridgeClient::EventStream::REDISCOVER_DELAYS) do |event|
170
+ queue << [:event, event]
171
+ end
162
172
  @reader = LineReader.new(queue, prompt: method(:prompt_text), read: input || method(:read_input_line),
163
173
  prefill: default_input_text).start
164
174
  loop do
@@ -237,6 +247,8 @@ module Samagotchi
237
247
  end_turn
238
248
  own_turn_ended(event)
239
249
  when :prompt_restored then restore_prompt(event)
250
+ # The after_turn hooks are done (their notices came before it).
251
+ when :answer_display then @display_pending = false
240
252
  when :context_status, :used_memories_updated then @status.take_event(event)
241
253
  # Another client's anytime command: its line now, before the cards
242
254
  # it shows (its command_ran comes when it's done).
@@ -278,6 +290,7 @@ module Samagotchi
278
290
  when :generation_completed
279
291
  @status.take_event(event)
280
292
  @renderer.call(event)
293
+ when :worker_changed then switch_worker(event)
281
294
  when :stream_closed
282
295
  @view.finish_thinking_spinner
283
296
  @screen.commit("Lost the session's worker (#{event[:reason]}). " \
@@ -290,6 +303,32 @@ module Samagotchi
290
303
 
291
304
  private
292
305
 
306
+ # The newest chi installed, looked at once, at the join.
307
+ def installed_version
308
+ @installed_version.call
309
+ rescue StandardError
310
+ nil
311
+ end
312
+
313
+ # The session's live worker for the stream to follow (EventStream's
314
+ # rediscover): nil while there is none yet, :gone once the session was
315
+ # stopped (no worker is coming).
316
+ def live_worker
317
+ id = @client.session_id
318
+ return :gone if Session.stopped_marker?(id)
319
+
320
+ BridgeClient.discover(id, session_dir: Session.session_dir(id), host: @client.host)
321
+ end
322
+
323
+ # The stream moved to a new worker (a restart): requests go there too.
324
+ def switch_worker(event)
325
+ @client = BridgeClient.new(session_id: @client.session_id, port: event[:port], host: @client.host)
326
+ @worker_changed = true
327
+ @view.finish_thinking_spinner
328
+ Log.info(:attached, "worker_changed", port: event[:port])
329
+ nil
330
+ end
331
+
293
332
  def next_item(queue)
294
333
  queue.pop
295
334
  rescue Interrupt
@@ -580,9 +619,10 @@ module Samagotchi
580
619
  @screen.commit("could not send the prompt (#{worker_down(e)})")
581
620
  end
582
621
 
583
- # Whether the input's end must wait: the -p prompt isn't sent yet, or
584
- # a prompt this run sent hasn't had its turn.
585
- def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty?
622
+ # Whether the input's end must wait: the -p prompt isn't sent yet, a
623
+ # prompt this run sent hasn't had its turn, or its turn's after_turn
624
+ # hooks still run (their notices, source-links' sources:, come next).
625
+ def own_turns_pending? = !@first_prompt.to_s.strip.empty? || !@open_ids.empty? || @display_pending == true
586
626
 
587
627
  # A turn ended: this run's prompt (and ours merged into it) had its turn.
588
628
  def own_turn_ended(event)
@@ -592,6 +632,8 @@ module Samagotchi
592
632
  ours = @open_ids.include?(id) || !@merged_ids.empty?
593
633
  @own_failed = true if ours && event[:type] == :turn_failed
594
634
  @own_empty = true if ours && event[:type] == :turn_completed && event.dig(:turn_summary, :empty_answer)
635
+ # The worker sends answer_display once its after_turn hooks ran.
636
+ @display_pending = true if ours && event[:type] == :turn_completed && event[:display_pending]
595
637
  @open_ids.delete(id)
596
638
  @open_ids.subtract(@merged_ids)
597
639
  @merged_ids.clear
@@ -879,8 +921,13 @@ module Samagotchi
879
921
 
880
922
  def render_snapshot(snapshot, reset:)
881
923
  @view.finish_thinking_spinner
924
+ # A new worker's first snapshot is the session as it now stands.
925
+ moved = @worker_changed
926
+ reset ||= moved
927
+ @worker_changed = false
882
928
  if @attached
883
- @screen.commit("(resynced with the session)") if reset
929
+ @screen.commit("(resynced with the session)") if reset && !moved
930
+ @screen.commit(paint(VersionLines.restarted(snapshot[:chi_version]), 2)) if moved
884
931
  else
885
932
  @attached = true
886
933
  # The recap first: what the session was about, then where it stopped.
@@ -889,6 +936,9 @@ module Samagotchi
889
936
  render_join_header(Array(snapshot[:messages]))
890
937
  @screen.commit("guardrails> #{snapshot[:guardrail_warning]}") if snapshot[:guardrail_warning]
891
938
  @screen.commit("plugins> #{snapshot[:plugin_warning]}") if snapshot[:plugin_warning]
939
+ version_line = VersionLines.at_attach(worker: snapshot[:chi_version], installed: installed_version,
940
+ session_id: @client.session_id)
941
+ @screen.commit(paint(version_line, 2)) if version_line
892
942
  end
893
943
  if snapshot[:commands]
894
944
  @command_registry = Commands::Registry.from_listing(snapshot[:commands], base: SessionCommands.builtin_registry)
@@ -9,20 +9,27 @@ module Samagotchi
9
9
  # stays as typed; a token that isn't an image (a source file, a missing
10
10
  # path, an email address) is just text.
11
11
  #
12
- # @shot.png @~/Desktop/a.jpg @"my shot.png" @'my shot.png'
12
+ # @shot.png @~/Desktop/a.jpg @"my shot.png" @'my shot.png' @my\ shot.png
13
+ #
14
+ # A bare path takes backslash escapes the way a shell does (what a
15
+ # Finder drag into the terminal types): `\ ` is a space, `\(` a `(`.
13
16
  module ImageInput
14
- # An @ at the start or after whitespace, then a quoted or bare path.
15
- TOKEN_RE = /(?:\A|(?<=\s))@(?:"([^"]+)"|'([^']+)'|(\S+))/
17
+ # An @ at the start or after whitespace, then a quoted or bare path
18
+ # (a bare one runs on through backslash-escaped whitespace).
19
+ TOKEN_RE = /(?:\A|(?<=\s))@(?:"([^"]+)"|'([^']+)'|((?:\\\s|\S)+))/
16
20
 
17
21
  # @return [Array<Hash>] {path:} per image, in order, each path once
18
22
  def self.extract(text, cwd: Dir.pwd)
19
23
  text.to_s.scan(TOKEN_RE).filter_map do |quoted_double, quoted_single, bare|
20
- raw = quoted_double || quoted_single || bare.sub(/[,.;:!?)\]]+\z/, "")
24
+ raw = quoted_double || quoted_single || unescape(bare.sub(/(?<!\\)[,.;:!?)\]]+\z/, ""))
21
25
  path = expand(raw, cwd)
22
26
  path if path && ImageStore.image_file?(path)
23
27
  end.uniq.map { |path| { path: path } }
24
28
  end
25
29
 
30
+ def self.unescape(bare) = bare.gsub(/\\(.)/m, '\\1')
31
+ private_class_method :unescape
32
+
26
33
  def self.expand(raw, cwd)
27
34
  return nil if raw.empty?
28
35