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
@@ -25,6 +25,8 @@ require_relative "cli/flags"
25
25
  require_relative "cli/parent_wait"
26
26
  require_relative "send_command/parent_gate"
27
27
  require_relative "send_command/wait_target"
28
+ require_relative "send_command/busy"
29
+ require_relative "session_access/local"
28
30
 
29
31
  module Samagotchi
30
32
  # `chi send`: put text into sessions as the user's message, the same as
@@ -67,7 +69,8 @@ module Samagotchi
67
69
  --new start a new session with the message instead, as the
68
70
  web does, and print its id
69
71
  --dir DIR (--new) its folder, the project it belongs to; default
70
- the current one
72
+ the current one (with --remote: required, that
73
+ machine's absolute path)
71
74
  --continues ID
72
75
  (--new) start the next link of that session's chain:
73
76
  in its folder, on its model (or --model's; one whose
@@ -145,8 +148,9 @@ module Samagotchi
145
148
  queue), with --cut also cut (now, waits, off)
146
149
  Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
147
150
  question waits, 4 still running (--timeout), 130 Ctrl-C.
148
- Only sessions on this machine. Answers show in the attached TUI
149
- or web page, not here.
151
+ Sessions on this machine, or another's: chi --remote NAME send …
152
+ (chi remote). Answers show in the attached TUI or web page, not
153
+ here.
150
154
  Find ids with: chi sessions list --live [--scope=all] [--format tsv]
151
155
  TEXT
152
156
 
@@ -206,18 +210,44 @@ module Samagotchi
206
210
 
207
211
  UNKNOWN_VISION = VisionSupport::Answer.new(value: nil, reason: nil)
208
212
 
213
+ # What to say when +answer+ (.vision_answer's for +model_name+) is a
214
+ # no, or nil: the words chi send and chi web's turn and create routes
215
+ # refuse images with.
216
+ def self.vision_refusal(model_name, answer)
217
+ return nil unless answer.no?
218
+
219
+ name = model_name.to_s.strip
220
+ name = Samagotchi::Config.get("default.model").to_s.strip if name.empty?
221
+ name = "the model" if name.empty?
222
+ "#{name} can't take images (#{answer.reason}); send text only or switch the model (/model)"
223
+ end
224
+
225
+ # The model a --continues link of +previous+ runs, for the images
226
+ # check: +model+'s (blank: the previous link's, else the default it
227
+ # falls back to; SessionContinue.model_for).
228
+ # @return [Array(String, String)] [model_name, model_typed]
229
+ # @raise [ModelProfile::MissingModel] a model it can't start on
230
+ def self.continued_model(previous, model)
231
+ choice = SessionContinue.model_for(previous, model)
232
+ inherited = model.to_s.strip.empty? && choice.warning.nil?
233
+ inherited ? [previous.model_name, previous.model_typed] : [choice.model, nil]
234
+ end
235
+
209
236
  # A no from the config (models: / hosts: vision: false) holds without
210
237
  # the server.
211
238
  def self.configured_no?(answer) = answer.reason.to_s.include?("sets vision: false")
212
239
  private_class_method :configured_no?
213
240
 
214
241
  # @param argv [Array<String>] the arguments after "send"
215
- def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil)
242
+ # @param access [SessionAccess::Local, nil] where the sessions are;
243
+ # default this machine's, in +state_dir+
244
+ def initialize(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, state_dir: nil, access: nil)
216
245
  @argv = argv.dup
217
246
  @stdin = stdin
218
247
  @stdout = stdout
219
248
  @stderr = stderr
220
249
  @state_dir = state_dir || Session.default_state_dir
250
+ @access = access || SessionAccess::Local.new(state_dir: @state_dir)
221
251
  end
222
252
 
223
253
  private
@@ -259,17 +289,24 @@ module Samagotchi
259
289
 
260
290
  # A parent agent's /continue yes held to guardrails.parent_answers,
261
291
  # and a /parent-answers that loosens: refused before anything is sent.
262
- gate = ParentGate.new(options, state_dir: @state_dir)
263
- if prompt && (refusal = gate.continue_refusal(prompt))
264
- @stderr.puts("chi send: #{refusal}")
265
- return CLI::Exit::FAILED
266
- end
267
- if prompt && gate.loosens?(prompt)
268
- error_line("chi send: refused: #{ParentAnswersCommand::REFUSED}")
269
- return CLI::Exit::FAILED
292
+ # Machine-local (the targets' session files, this machine's
293
+ # config.yml): remotely the server's (its /turn and create routes
294
+ # check the line chi send's client_id marks; the worker checks a
295
+ # CLI_ANSWER /continue again).
296
+ unless @access.remote?
297
+ gate = ParentGate.new(options, state_dir: @state_dir)
298
+ if prompt && (refusal = gate.continue_refusal(prompt))
299
+ @stderr.puts("chi send: #{refusal}")
300
+ return CLI::Exit::FAILED
301
+ end
302
+ if prompt && gate.loosens?(prompt)
303
+ error_line("chi send: refused: #{ParentAnswersCommand::REFUSED}")
304
+ return CLI::Exit::FAILED
305
+ end
270
306
  end
271
307
  # A file that is not there or not an image: a run-time failure (1),
272
308
  # reported in the --format json line too; --image itself was fine.
309
+ # Client-side on any backend: the files are this machine's.
273
310
  return CLI::Exit::FAILED unless ingest_images(options[:images])
274
311
 
275
312
  return run_new(prompt, options) if options[:new]
@@ -372,6 +409,8 @@ module Samagotchi
372
409
 
373
410
  options[:parent_answers] = level
374
411
  end
412
+ return remote_new_options(options, words) if @access.remote?
413
+
375
414
  toolkit = Toolkit.from_flags(name: options[:toolkit], add: options[:tools], remove: options[:no_tools])
376
415
  options[:toolkit_value] = toolkit if toolkit
377
416
  begin
@@ -387,6 +426,35 @@ module Samagotchi
387
426
  options
388
427
  end
389
428
 
429
+ # --new against a remote: its folder is given, as that machine's
430
+ # absolute path (D4: none is guessed from this one's), and the setup
431
+ # flags go as typed (SetupWords): the toolkit's default, the bundles a
432
+ # --set names and the folder are the remote's to check.
433
+ def remote_new_options(options, llm_words)
434
+ unless options[:continues]
435
+ # One line: the fix is in it, the whole usage would bury it.
436
+ return usage_error("--new on #{remote_name} needs --dir: a folder there", usage: nil) unless options[:dir]
437
+ unless options[:dir].start_with?("/")
438
+ return usage_error("--dir is a folder on #{remote_name}: give its absolute path", usage: nil)
439
+ end
440
+ end
441
+
442
+ settings = Array(options[:sets]).to_h do |pair|
443
+ key, sep, value = pair.partition("=")
444
+ return usage_error("--set takes KEY=VALUE, not #{pair}") if sep.empty? || key.strip.empty?
445
+
446
+ [key.strip, value]
447
+ end
448
+ options[:setup_words] = SessionAccess::SetupWords.new(
449
+ llm_context: (llm_words unless llm_words.empty?), thinking: options[:thinking]&.to_s,
450
+ toolkit: options[:toolkit], tools: Array(options[:tools]), no_tools: Array(options[:no_tools]),
451
+ settings: (settings unless settings.empty?), parent_answers: options[:parent_answers]
452
+ )
453
+ options
454
+ end
455
+
456
+ def remote_name = @access.respond_to?(:name) ? @access.name : "the remote"
457
+
390
458
  # With both, stdin is the context quoted above the message; with one,
391
459
  # it goes in as is. nil when both are blank.
392
460
  def compose(context, message)
@@ -409,6 +477,8 @@ module Samagotchi
409
477
  model, typed = continued_model(options)
410
478
  return 1 if model == false
411
479
 
480
+ # Machine-local (this machine's model registry): remotely the
481
+ # server's (P2: the create route checks vision).
412
482
  if images && (refusal = vision_refusal(model, typed: typed))
413
483
  error_line("chi send: refused: #{refusal}")
414
484
  return 1
@@ -419,21 +489,11 @@ module Samagotchi
419
489
  # says whether it ran as one.
420
490
  through_worker = images || command_like?(prompt)
421
491
  begin
422
- start = through_worker ? { prompt: nil, preview: prompt } : { prompt: prompt }
423
- session = if options[:continues]
424
- SessionManager.continue_session(options[:continues], **start, model: options[:model],
425
- state_dir: @state_dir)
426
- else
427
- SessionManager.spawn_session(**start, working_directory: options[:dir],
428
- model_name: options[:model],
429
- setup: SessionSetup.new(llm_context: options[:llm_context],
430
- thinking: options[:thinking],
431
- toolkit: options[:toolkit_value],
432
- settings: options[:settings],
433
- parent_answers: options[:parent_answers]),
434
- state_dir: @state_dir)
435
- end
436
- rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI => e
492
+ session = @access.create(new_session_spec(prompt, options, through_worker: through_worker))
493
+ rescue SessionAccess::BadRequest => e
494
+ return usage_error(e.message)
495
+ rescue SessionContinue::Refused, SessionManager::ArchiveRefused, SessionManager::OwnedByTUI,
496
+ SessionAccess::Refused => e
437
497
  error_line("chi send: refused: #{e.message}")
438
498
  return 1
439
499
  rescue StandardError => e
@@ -441,9 +501,13 @@ module Samagotchi
441
501
  return 1
442
502
  end
443
503
  error_line("chi send: warning: #{session.model_warning}") if session.model_warning
444
- if (capped = Guardrails::ParentPolicy.capped_note(options[:parent_answers]))
445
- error_line("chi send: warning: --parent-answers #{capped}")
446
- end
504
+ # Remotely the server's config.yml caps it (its create's warning).
505
+ capped = if @access.remote?
506
+ session.parent_answers_warning
507
+ else
508
+ Guardrails::ParentPolicy.capped_note(options[:parent_answers])
509
+ end
510
+ error_line("chi send: warning: --parent-answers #{capped}") if capped
447
511
  if through_worker
448
512
  return 1 unless deliver_new(session, prompt)
449
513
  else
@@ -453,23 +517,45 @@ module Samagotchi
453
517
  return 0 unless options[:wait]
454
518
  return report_command(session.id) if @sent_command
455
519
 
456
- wait_for_reply(session.id, cursor: nil, baseline: baseline_of(session, question_id: nil),
520
+ start = @access.prepare_wait(session.id, session: session, created: true)
521
+ wait_for_reply(session.id, cursor: start.cursor, baseline: start.baseline,
457
522
  timeout: options[:timeout], **WaitTarget.first_message(@delivered).to_h)
458
523
  end
459
524
 
525
+ # What chi send --new asks to create: through the worker (images, a
526
+ # session command), it starts idle with the message as its preview.
527
+ # Without --dir its folder is the current one (spawn_session's
528
+ # default): machine-local, so remotely --dir is required (D4).
529
+ # @return [SessionAccess::NewSession]
530
+ def new_session_spec(prompt, options, through_worker:)
531
+ start = through_worker ? { prompt: nil, preview: prompt } : { prompt: prompt }
532
+ # For a remote's create route, which checks them as this machine does
533
+ # before it creates (the first line's client_id, the images' model).
534
+ start = start.merge(client_id: client_id_for(prompt), images: !@images.empty?)
535
+ if options[:continues]
536
+ return SessionAccess::NewSession.new(**start, continues: options[:continues], model_name: options[:model])
537
+ end
538
+
539
+ setup = options[:setup_words] || SessionSetup.new(llm_context: options[:llm_context], thinking: options[:thinking],
540
+ toolkit: options[:toolkit_value], settings: options[:settings],
541
+ parent_answers: options[:parent_answers])
542
+ SessionAccess::NewSession.new(**start, working_directory: options[:dir], model_name: options[:model], setup: setup)
543
+ end
544
+
460
545
  # The model the new session will run, for the images check: --model
461
546
  # (nil: the default), or with --continues the one the new link will run
462
547
  # (SessionContinue.model_for: --model's, else the previous link's, else
463
- # the default it falls back to).
548
+ # the default it falls back to). Machine-local (the previous link's
549
+ # file, this machine's models): remotely the server's, with the vision
550
+ # check (P2).
464
551
  # @return [Array, false] [model_name, model_typed], or false after the
465
552
  # error line (no such session, a model it can't start on)
466
553
  def continued_model(options)
467
- return [options[:model], nil] unless options[:continues]
554
+ # Remotely the server reads the previous link (its vision check).
555
+ return [options[:model], nil] unless options[:continues] && !@access.remote?
468
556
 
469
557
  previous = Session.load(SessionChain.resolve(options[:continues], state_dir: @state_dir), state_dir: @state_dir)
470
- choice = SessionContinue.model_for(previous, options[:model])
471
- inherited = options[:model].to_s.strip.empty? && choice.warning.nil?
472
- inherited ? [previous.model_name, previous.model_typed] : [choice.model, nil]
558
+ self.class.continued_model(previous, options[:model])
473
559
  rescue ModelProfile::MissingModel => e
474
560
  error_line("chi send: could not start a session: #{e.message}")
475
561
  false
@@ -495,40 +581,21 @@ module Samagotchi
495
581
  # @return [Integer] the exit status
496
582
  def run_wait(prompt, options)
497
583
  id = resolve(options[:ids].first) or return 1
498
- session = Session.load(id, state_dir: @state_dir)
499
- if session.status == Session::STATUS_RUNNING && (owner = SessionManager.session_owner(id, state_dir: @state_dir)) &&
500
- (!@queue || session.waiting_question(live: owner.worker?))
501
- # The turn waits on a question: the message would only queue
502
- # behind it, and the answer goes in with chi answer.
503
- if (waiting = session.waiting_question(live: owner.worker?))
504
- # The whole question (its options, its tags) for what a parent may
505
- # give: config.yml tightened by the session's own parent_answers.
506
- strategy = Guardrails::ParentPolicy.for_session(session)
507
- question = ParentReport.question(session.pending_question, strategy: strategy)
508
- if ParentReport.approval?(waiting)
509
- error_line("#{id[0, 8]} waiting for an approval: #{ParentReport.approval_advice(question, strategy: strategy)} " \
510
- "(#{ParentReport.answer_with(id, question)})")
511
- else
512
- error_line("#{id[0, 8]} waiting for an answer: #{ParentReport.answer_with(id, question)} (or --text); " \
513
- "chi --attach #{id} shows it")
514
- end
515
- else
516
- error_line("#{id[0, 8]} busy: a turn is running; wait or attach")
517
- end
584
+ # Local: one read for the check and the baseline; remotely the
585
+ # server's wait prepare route reads both (Busy's words).
586
+ session = @access.show(id) unless @access.remote?
587
+ if (busy = @access.busy(id, queue: @queue, session: session))
588
+ error_line("#{id[0, 8]} #{busy.message(via: @access.invocation)}")
518
589
  return 1
519
590
  end
520
591
 
521
- cursor = ReplyWait.newest_reply(id, state_dir: @state_dir)
522
- baseline = baseline_of(session)
523
- running_turn = if session.status == Session::STATUS_RUNNING
524
- ReplyWait.own_turn(session, session_dir: Session.session_dir(id, state_dir: @state_dir))
525
- end
592
+ start = @access.prepare_wait(id, session: session)
526
593
  return 1 unless deliver(id, prompt)
527
594
  return report_command(id) if @sent_command
528
595
 
529
- target = WaitTarget.message(@delivered, queue: @queue, running: session.status == Session::STATUS_RUNNING,
530
- running_turn: running_turn)
531
- wait_for_reply(id, cursor: cursor, baseline: baseline, timeout: options[:timeout], **target.to_h)
596
+ target = WaitTarget.message(@delivered, queue: @queue, running: start.running,
597
+ running_turn: (start.own_turn if start.running))
598
+ wait_for_reply(id, cursor: start.cursor, baseline: start.baseline, timeout: options[:timeout], **target.to_h)
532
599
  end
533
600
 
534
601
  # --wait with no message: the session's next reply, sending nothing,
@@ -546,24 +613,15 @@ module Samagotchi
546
613
  # @return [Integer] the exit status
547
614
  def run_wait_only(options)
548
615
  id = resolve(options[:ids].first) or return 1
549
- session = Session.load(id, state_dir: @state_dir)
550
- session_dir = Session.session_dir(id, state_dir: @state_dir)
551
616
  requested_turn = options[:turn]&.to_s
552
- if requested_turn && !ReplyWait.known_turn?(session, session_dir: session_dir, turn: requested_turn)
617
+ start = @access.prepare_wait(id, turn: requested_turn)
618
+ if requested_turn && !start.known_turn
553
619
  error_line("chi send: turn #{requested_turn} is not a running, queued, or last ended turn")
554
620
  return 1
555
621
  end
556
- live = session.status == Session::STATUS_RUNNING || SessionManager.session_owner(id, state_dir: @state_dir)
557
- target = WaitTarget.wait_only(session, session_dir: session_dir, requested: requested_turn)
558
- wait_for_reply(id, cursor: ReplyWait.newest_reply(id, state_dir: @state_dir),
559
- baseline: baseline_of(session),
560
- timeout: options[:timeout], owner_grace: live ? self.class::WORKER_GONE_AFTER : nil, **target.to_h)
561
- end
562
-
563
- # The session as it was before the message went in (ReplyWait's
564
- # baseline).
565
- def baseline_of(session, **)
566
- ReplyWait.baseline_of(session, **)
622
+ target = WaitTarget.after(start, requested: requested_turn)
623
+ wait_for_reply(id, cursor: start.cursor, baseline: start.baseline,
624
+ timeout: options[:timeout], owner_grace: start.live ? self.class::WORKER_GONE_AFTER : nil, **target.to_h)
567
625
  end
568
626
 
569
627
  # A new idle session's first turn, with the images. Its worker's Bridge
@@ -571,14 +629,15 @@ module Samagotchi
571
629
  # and the spawned one takes its lock only once it runs.
572
630
  # @return [Boolean] whether the message was queued
573
631
  def deliver_new(session, prompt)
574
- dir = Session.session_dir(session.id, state_dir: @state_dir)
575
632
  result = nil
576
633
  begin
577
- refs = copy_images(dir)
578
- if BridgeClient.wait_for(session.id, session_dir: dir, timeout: SessionManager::TURN_BRIDGE_WAIT)
579
- result = SessionManager.deliver_turn(session.id, prompt: prompt, client_id: client_id_for(prompt), images: refs,
580
- state_dir: @state_dir)
634
+ refs = upload_images(session.id)
635
+ if @access.await_worker(session.id, timeout: SessionManager::TURN_BRIDGE_WAIT)
636
+ result = @access.deliver_turn(session.id, prompt: prompt, client_id: client_id_for(prompt), images: refs)
581
637
  end
638
+ rescue SessionAccess::Refused => e
639
+ @info.puts("#{session.id} refused: #{e.message}")
640
+ return false
582
641
  rescue StandardError => e
583
642
  @info.puts("#{session.id} failed: #{e.message}")
584
643
  return false
@@ -586,11 +645,12 @@ module Samagotchi
586
645
  # Kept, not deleted: it holds the images, and the message shows as
587
646
  # its preview; the user can attach and send it again.
588
647
  unless result
589
- @info.puts("#{session.id} failed: its worker did not start; the session is kept (chi --attach #{session.id})")
648
+ attach = @access.invocation.attach(session.id)
649
+ @info.puts("#{session.id} failed: its worker did not start; the session is kept#{" (#{attach})" if attach}")
590
650
  return false
591
651
  end
592
652
  unless result.accepted?
593
- @info.puts("#{session.id} failed: #{result.detail || "could not queue it"}")
653
+ @info.puts("#{session.id} #{refused_word(result)}: #{result.detail || "could not queue it"}")
594
654
  return false
595
655
  end
596
656
 
@@ -602,6 +662,19 @@ module Samagotchi
602
662
 
603
663
  SENT_COMMAND = "sent as a session command"
604
664
 
665
+ # A remote's refusals of what this machine checks before sending (a
666
+ # parent's /continue yes, a loosening /parent-answers, images to a
667
+ # model that can't see them): its turn route's error codes.
668
+ REMOTE_REFUSALS = %w[parent_answers_refused parent_continue_refused no_vision].freeze
669
+
670
+ # How a delivery that didn't go in reads: refused (a worker older than
671
+ # queueing would steer it; a remote refused it as chi send refuses it
672
+ # here) or failed.
673
+ def refused_word(result)
674
+ refused = result.error == Delivery::QUEUE_UNAVAILABLE["error"] || REMOTE_REFUSALS.include?(result.error)
675
+ refused ? "refused" : "failed"
676
+ end
677
+
605
678
  # chi send's client id, or chi answer's for a parent agent's /continue
606
679
  # yes (the worker holds that to its guardrails.parent_answers).
607
680
  def client_id_for(prompt) = ParentGate.parent_continue?(prompt) ? ClientId::CLI_ANSWER : CLIENT_ID
@@ -636,13 +709,13 @@ module Samagotchi
636
709
  CLI::Exit::FAILED
637
710
  end
638
711
 
639
- def local_refusal(name, id) = SessionCommands.ui_only_refusal(name, id, runner: "chi send")
712
+ def local_refusal(name, id) = SessionCommands.ui_only_refusal(name, id, runner: "chi send", remote: @access.invocation.remote)
640
713
 
641
714
  # --wait after a message that ran as a session command: no reply comes
642
715
  # for it. Its output shows where the session is open.
643
716
  # @return [Integer] 0
644
717
  def report_command(id)
645
- detail = "#{SENT_COMMAND}: no reply to wait for (its output shows in chi --attach #{id} or the web)"
718
+ detail = "#{SENT_COMMAND}: no reply to wait for (its output shows in #{@access.invocation.open_it(id)})"
646
719
  if @json
647
720
  @stdout.puts(JSON.generate({ status: "command", session_id: id, detail: detail }))
648
721
  @stdout.flush
@@ -656,14 +729,16 @@ module Samagotchi
656
729
  # @return [Boolean] whether the message was queued
657
730
  def deliver(id, prompt)
658
731
  short = id[0, 8]
659
- session = Session.load(id, state_dir: @state_dir)
732
+ session = @access.show(id)
733
+ # Machine-local (this machine's model registry): remotely the
734
+ # server's (P2), as for --new.
660
735
  if !@images.empty? && (refusal = vision_refusal(session.model_name, typed: session.model_typed))
661
736
  @info.puts("#{short} refused: #{refusal}")
662
737
  return false
663
738
  end
664
- owner = SessionManager.session_owner(id, state_dir: @state_dir)
739
+ owner = @access.owner(id)
665
740
  running = owner && session.status == Session::STATUS_RUNNING
666
- refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
741
+ refs = upload_images(id)
667
742
  # --cut asks the running turn for a cut (to an idle session an
668
743
  # ordinary message: nothing to cut). --queue always goes as a queue,
669
744
  # idle or not: the Bridge acks next_step to an idle worker and the
@@ -673,12 +748,9 @@ module Samagotchi
673
748
  delivery = if @queue then Delivery::QUEUE
674
749
  elsif @cut && running then Delivery::CUT
675
750
  end
676
- result = SessionManager.deliver_turn(id, prompt: prompt, client_id: client_id_for(prompt), images: refs, state_dir: @state_dir,
677
- delivery: delivery)
751
+ result = @access.deliver_turn(id, prompt: prompt, client_id: client_id_for(prompt), images: refs, delivery: delivery)
678
752
  unless result.accepted?
679
- # A worker older than queueing would steer it: refused, not sent.
680
- word = result.error == Delivery::QUEUE_UNAVAILABLE["error"] ? "refused" : "failed"
681
- @info.puts("#{short} #{word}: #{result.detail || "could not queue it"}")
753
+ @info.puts("#{short} #{refused_word(result)}: #{result.detail || "could not queue it"}")
682
754
  return false
683
755
  end
684
756
 
@@ -755,14 +827,12 @@ module Samagotchi
755
827
  # What to say when +model_name+ is known not to take images, or nil
756
828
  # (it does, or it is unknown: sent as before). Asked once per model.
757
829
  def vision_refusal(model_name, typed: nil)
830
+ # Remotely the server's (check_vision on its turn and create routes).
831
+ return nil if @access.remote?
832
+
758
833
  @vision ||= {}
759
834
  answer = @vision[[model_name.to_s, typed.to_s]] ||= self.class.vision_answer(model_name, typed: typed)
760
- return nil unless answer.no?
761
-
762
- name = model_name.to_s.strip
763
- name = Samagotchi::Config.get("default.model").to_s.strip if name.empty?
764
- name = "the model" if name.empty?
765
- "#{name} can't take images (#{answer.reason}); send text only or switch the model (/model)"
835
+ self.class.vision_refusal(model_name, answer)
766
836
  end
767
837
 
768
838
  # Each --image read, converted and downscaled once, into a scratch
@@ -781,13 +851,8 @@ module Samagotchi
781
851
  false
782
852
  end
783
853
 
784
- # The images' files copied into a session dir, as the refs a turn takes.
785
- def copy_images(session_dir)
786
- @images.map do |ref|
787
- ImageStore.copy_file(ref, from: @image_dir, to: session_dir)
788
- { file: ref[:file], name: ref[:name] }
789
- end
790
- end
854
+ # The images' files copied into the session, as the refs a turn takes.
855
+ def upload_images(id) = @access.upload_images(id, @images, from: @image_dir)
791
856
 
792
857
  def with_images
793
858
  case @images.size
@@ -12,6 +12,7 @@ require_relative "pending_question"
12
12
  require_relative "llm_context_override"
13
13
  require_relative "session_setup"
14
14
  require_relative "prompt_note"
15
+ require_relative "working_set_pin"
15
16
 
16
17
  require_relative "paths"
17
18
  require_relative "project_scope"
@@ -249,6 +250,17 @@ module Samagotchi
249
250
  end
250
251
  end
251
252
 
253
+ # The working set's items the user pinned (/working-set pin;
254
+ # WorkingSetPin), [] with none: the model's forget_outputs refuses
255
+ # their outputs. A respawn, a continue and a fork keep them
256
+ # (SessionSetup.of); a delegate child starts without.
257
+ attr_reader :working_set_pins
258
+
259
+ # @param value [Array<WorkingSetPin, Hash>] the session file's form too
260
+ def working_set_pins=(value)
261
+ @working_set_pins = WorkingSetPin.list(value)
262
+ end
263
+
252
264
  # The hook that stopped the last turn (stop_turn, or a cut with no retry
253
265
  # left), e.g. "loop-guard"; nil when it ended otherwise.
254
266
  def stopped_by
@@ -283,7 +295,8 @@ module Samagotchi
283
295
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
284
296
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
285
297
  continues: nil, thinking: nil, reasoning_history: nil, appended_prompt_files: [],
286
- toolkit: nil, settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil)
298
+ toolkit: nil, settings: nil, interrupted_turn: nil, parent_answers: nil, title: nil,
299
+ working_set_pins: [])
287
300
  @id = id
288
301
  @metadata_version = metadata_version
289
302
  @mode = mode
@@ -316,6 +329,7 @@ module Samagotchi
316
329
  self.toolkit = toolkit
317
330
  self.settings = settings
318
331
  self.parent_answers = parent_answers
332
+ self.working_set_pins = working_set_pins
319
333
  self.interrupted_turn = interrupted_turn
320
334
  @turn_checkpoint = nil
321
335
  @archived = false
@@ -415,6 +429,7 @@ module Samagotchi
415
429
  toolkit: setup.toolkit,
416
430
  settings: setup.settings,
417
431
  parent_answers: setup.parent_answers,
432
+ working_set_pins: setup.working_set_pins,
418
433
  continues: continues
419
434
  )
420
435
  end
@@ -484,7 +499,8 @@ module Samagotchi
484
499
  "bundle_settings" => {},
485
500
  "config_settings" => {},
486
501
  "interrupted_turn" => nil,
487
- "parent_answers" => nil
502
+ "parent_answers" => nil,
503
+ "working_set_pins" => []
488
504
  }.freeze
489
505
 
490
506
  # A session from a parsed session file. +messages+ false leaves the
@@ -499,8 +515,16 @@ module Samagotchi
499
515
  new(**attrs)
500
516
  end
501
517
 
502
- # A prefix that names more than one session.
503
- class AmbiguousId < ArgumentError; end
518
+ # A prefix that names more than one session: +ids+ are the sessions it
519
+ # names (sorted), the message lists them with their first previews.
520
+ class AmbiguousId < ArgumentError
521
+ attr_reader :ids
522
+
523
+ def initialize(message = nil, ids: [])
524
+ @ids = ids
525
+ super(message)
526
+ end
527
+ end
504
528
 
505
529
  # A session id or a unique prefix of one (like git's): the full id. An
506
530
  # unknown one comes back as is, for the caller's own "not found"; one that
@@ -518,7 +542,7 @@ module Samagotchi
518
542
  rescue JSON::ParserError, SystemCallError
519
543
  " #{match}"
520
544
  end
521
- raise AmbiguousId, "session id #{id} matches #{matches.size} sessions:\n#{lines.join("\n")}"
545
+ raise AmbiguousId.new("session id #{id} matches #{matches.size} sessions:\n#{lines.join("\n")}", ids: matches)
522
546
  end
523
547
 
524
548
  # Return all saved sessions sorted by updated_at desc by default (newest first).
@@ -627,6 +651,7 @@ module Samagotchi
627
651
  when "config_settings" then @settings.core
628
652
  when "prompt_notes" then @prompt_notes.map(&:to_file)
629
653
  when "last_turn" then @last_turn&.to_file
654
+ when "working_set_pins" then @working_set_pins.map(&:to_file)
630
655
  when "used_memory_names", "preloaded_memory_names", "muted_memory_names", "appended_prompt_files"
631
656
  Array(instance_variable_get(:"@#{key}"))
632
657
  else instance_variable_get(:"@#{key}")