samagotchi 0.44.0 → 0.46.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +120 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +43 -12
  5. data/docs/configuration.md +102 -5
  6. data/docs/context.md +2 -1
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-live.md +111 -0
  10. data/docs/plugins.md +4 -2
  11. data/docs/sessions.md +9 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +19 -0
  13. data/lib/samagotchi/bridge.rb +22 -10
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  18. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  19. data/lib/samagotchi/commands/registry.rb +54 -9
  20. data/lib/samagotchi/config.rb +57 -4
  21. data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
  22. data/lib/samagotchi/engine.rb +47 -9
  23. data/lib/samagotchi/host_model.rb +116 -0
  24. data/lib/samagotchi/host_registry.rb +43 -12
  25. data/lib/samagotchi/kernel_loop.rb +2 -1
  26. data/lib/samagotchi/llm/chat_loop.rb +89 -7
  27. data/lib/samagotchi/llm/turn_settings.rb +5 -3
  28. data/lib/samagotchi/llm_context_strategy.rb +37 -0
  29. data/lib/samagotchi/model_catalog.rb +17 -12
  30. data/lib/samagotchi/model_notes.rb +8 -6
  31. data/lib/samagotchi/model_overlay.rb +3 -2
  32. data/lib/samagotchi/model_price.rb +54 -0
  33. data/lib/samagotchi/model_profile.rb +74 -3
  34. data/lib/samagotchi/plugin/context.rb +19 -7
  35. data/lib/samagotchi/process_group.rb +56 -9
  36. data/lib/samagotchi/recap_setup.rb +1 -1
  37. data/lib/samagotchi/self_report.rb +33 -3
  38. data/lib/samagotchi/session_commands.rb +47 -21
  39. data/lib/samagotchi/session_manager.rb +19 -1
  40. data/lib/samagotchi/session_metrics.rb +79 -11
  41. data/lib/samagotchi/sessions_command.rb +15 -5
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
  43. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
  44. data/lib/samagotchi/terminal_ui/formatting.rb +45 -10
  45. data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
  46. data/lib/samagotchi/terminal_ui.rb +97 -12
  47. data/lib/samagotchi/tools/delegate_wait.rb +5 -4
  48. data/lib/samagotchi/tools/task_runtime.rb +11 -2
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +12 -11
  51. data/lib/samagotchi/web/public/app.js +30 -18
  52. data/lib/samagotchi/web/public/command_complete.js +3 -1
  53. data/lib/samagotchi/web/public/ctx.js +62 -22
  54. data/lib/samagotchi/web/public/format.js +7 -2
  55. data/lib/samagotchi/web/public/index.html +3 -1
  56. data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
  57. data/lib/samagotchi/web/public/model_pick.js +23 -3
  58. data/lib/samagotchi/web/public/model_picker.js +5 -3
  59. data/lib/samagotchi/web/public/stage_model.js +18 -4
  60. data/lib/samagotchi/web/public/stage_view.js +4 -3
  61. data/lib/samagotchi/web/public/turn_events.js +19 -6
  62. data/lib/samagotchi/web/public/turn_model.js +16 -0
  63. data/lib/samagotchi/web/public/turn_view.js +19 -0
  64. data/lib/samagotchi/web/session_hub.rb +9 -1
  65. data/lib/samagotchi/web/session_summary.rb +19 -2
  66. data/lib/samagotchi/worker.rb +172 -21
  67. data/lib/samagotchi/worker_inbound.rb +6 -5
  68. metadata +4 -1
@@ -16,6 +16,7 @@ require_relative "session_manager"
16
16
  require_relative "archive_store"
17
17
  require_relative "log"
18
18
  require_relative "turn_flow"
19
+ require_relative "llm/errors"
19
20
  require_relative "continue_offer"
20
21
  require_relative "iteration_limit"
21
22
  require_relative "session_commands"
@@ -59,6 +60,8 @@ module Samagotchi
59
60
  # How much of a command's output goes into its :command_ran.
60
61
  COMMAND_OUTPUT_LIMIT = 4096
61
62
  BUSY_OUTPUT = "busy: wait for the turn to end"
63
+ # !rollback mid-turn would mean "the turn I'm in".
64
+ ROLLBACK_BUSY_OUTPUT = "busy: Ctrl-C the turn first, then !rollback"
62
65
 
63
66
  # Wakes the worker loop. Whoever queues work writes it first and wakes
64
67
  # after, and #wait drains every wake before the loop looks for work: a
@@ -184,7 +187,10 @@ module Samagotchi
184
187
  # queued while that turn ran.
185
188
  @turn_end_seq = 0
186
189
  @engine.subscribe(observer: lambda { |event|
187
- @turn_end_seq = event[:event_seq] if Events::TURN_END.include?(event[:type])
190
+ if Events::TURN_END.include?(event[:type])
191
+ @turn_end_seq = event[:event_seq]
192
+ @turn_end_type = event[:type]
193
+ end
188
194
  @turn_id = event[:turn_id] if event[:type] == :turn_started
189
195
  ring_question(event) if event[:type] == :question_requested
190
196
  })
@@ -198,7 +204,9 @@ module Samagotchi
198
204
  context_absorber: @context_absorber, wakes: @wakes,
199
205
  awaiting_continue: -> { @turn_flow.awaiting_continue? },
200
206
  stopped: -> { stopped_on_disk? },
201
- queue_command: ->(line, client_id) { @bridge.queue_command(line, client_id: client_id) })
207
+ queue_command: lambda { |line, client_id, after_file|
208
+ @bridge.queue_command(line, client_id: client_id, after_file: after_file)
209
+ })
202
210
  # /model's default is the config's, as in the REPL; the Engine started
203
211
  # on the session's model.
204
212
  @default_model = ModelProfile.required_model_name(nil)
@@ -215,14 +223,7 @@ module Samagotchi
215
223
  def serve
216
224
  @bridge = SessionManager.start_bridge(engine: @engine, state_dir: @state_dir, session_id: @session_id,
217
225
  on_input: -> { @waker.wake },
218
- on_command: lambda { |command|
219
- next start_anytime_command(command) if anytime_command?(command[:line])
220
-
221
- # Called with the event log held: the
222
- # count says which turn ends came before it.
223
- @command_queue << command.merge(after_seq: @engine.event_count)
224
- @waker.wake
225
- },
226
+ on_command: method(:on_command),
226
227
  on_exit_request: method(:exit_request),
227
228
  # Decided again as it leaves (a note may still come in).
228
229
  exit_discards: method(:empty_session?))
@@ -307,6 +308,9 @@ module Samagotchi
307
308
  # A stop between two queued turns leaves the rest queued.
308
309
  break if stopped_on_disk?
309
310
 
311
+ # Commands that came before this file (D1: a queued /model X sent
312
+ # before this prompt, a command an earlier file carried) run first.
313
+ run_queued_commands
310
314
  @inbound.absorb_notes
311
315
  run_input_file(input_file)
312
316
  end
@@ -334,6 +338,7 @@ module Samagotchi
334
338
  # group) before the Engine does.
335
339
  def teardown
336
340
  @context_poller&.stop
341
+ drop_queued_commands
337
342
  @engine&.shutdown
338
343
  @bridge&.stop
339
344
  # The process exits next: a parent wake it rang for (a crash's
@@ -549,29 +554,122 @@ module Samagotchi
549
554
 
550
555
  def max_iterations(no_interrupt) = IterationLimit.for(no_interrupt: no_interrupt)
551
556
 
557
+ # The queued commands that may run now, in order, up to one whose
558
+ # after_file (D1) still waits in the inbox: it and those behind it stay
559
+ # queued.
552
560
  # @return [Boolean] whether any command ran
553
561
  def run_queued_commands
554
562
  ran = false
555
- while (command = next_command)
563
+ while (command = next_ready_command)
556
564
  run_command(command)
557
565
  ran = true
558
566
  end
559
567
  ran
560
568
  end
561
569
 
562
- # A command queued while a turn ran is refused (S1), not run after it:
570
+ # Pops the first queued command if it may run now (its after_file is
571
+ # taken), else none: one behind it waits too, in arrival order (an idle
572
+ # /model Y that came just after the turn ended stays behind a /model X
573
+ # still waiting for a prompt sent before it). With the event log held,
574
+ # as the Bridge queues.
575
+ def next_ready_command
576
+ return nil if @command_queue.empty?
577
+
578
+ @engine.synchronize_events do
579
+ commands = drain_commands
580
+ first = commands.first
581
+ ready = first && ready_command?(first) ? commands.shift : nil
582
+ commands.each { |command| @command_queue << command }
583
+ ready
584
+ end
585
+ end
586
+
587
+ def ready_command?(command)
588
+ return true if command[:after_file].nil?
589
+
590
+ SessionInbox.find_new_input_files(@session_dir).none? { |path| File.basename(path) <= command[:after_file] }
591
+ end
592
+
593
+ def drain_commands
594
+ commands = []
595
+ while (command = next_command)
596
+ commands << command
597
+ end
598
+ commands
599
+ end
600
+
601
+ # A command a turn refuses (:refuse while it ran, S1) is answered busy:
563
602
  # at the turn's iteration boundaries (mid_turn: all of them), and when
564
603
  # it ends (the ones queued before its end event; later ones run next).
604
+ # A queued one (:queue) stays for after the turn, but a !cmd after a
605
+ # canceled or failed turn (D4): running it would close the rollback
606
+ # window the turn left, so it is dropped. With the event log held, so
607
+ # a command the Bridge queues meanwhile stays behind the ones kept, in
608
+ # arrival order. One that comes after the empty look is the next
609
+ # boundary's (or end's), as it would be.
565
610
  def refuse_queued_commands(mid_turn: false)
566
- later = []
567
- while (command = next_command)
568
- if mid_turn || command[:after_seq].to_i < @turn_end_seq
569
- announce_command(command, status: "busy", output: BUSY_OUTPUT, changed: [])
570
- else
571
- later << command
611
+ return if @command_queue.empty?
612
+
613
+ @engine.synchronize_events do
614
+ drain_commands.each do |command|
615
+ during = mid_turn || command[:after_seq].to_i < @turn_end_seq
616
+ case (during ? turn_answer(command, ended: !mid_turn) : :keep)
617
+ when :refuse then announce_command(command, status: "busy", output: busy_output(command), changed: [])
618
+ when :drop then drop_command(waiting_for_turn_end(command), "turn #{@turn_end_type == :turn_failed ? "failed" : "canceled"}: " \
619
+ "#{command[:line]} not run; send it again")
620
+ else @command_queue << (during ? waiting_for_turn_end(command) : command)
621
+ end
572
622
  end
573
623
  end
574
- later.each { |command| @command_queue << command }
624
+ end
625
+
626
+ # What a turn does with +command+, queued while it ran: :refuse, :drop
627
+ # (D4, once it +ended+) or :keep. One queued idle just as the turn
628
+ # began (:loop) goes by its line's policy (a show form waits too).
629
+ def turn_answer(command, ended:)
630
+ policy = command[:mid_turn]
631
+ policy = @engine.command_registry.mid_turn(command[:line]) if policy == :loop
632
+ return :refuse if policy == :refuse
633
+ return :drop if ended && policy == :queue && shell_command?(command) &&
634
+ %i[turn_canceled turn_failed].include?(@turn_end_type)
635
+
636
+ :keep
637
+ end
638
+
639
+ # +command+, kept for after the turn. One queued idle just as the turn
640
+ # began (:loop) whose line waits for a turn's end (/model X, !cmd) is
641
+ # marked as one queued mid-turn: its command_queued (waits) tells the
642
+ # UIs now, and its command_ran says queued.
643
+ def waiting_for_turn_end(command)
644
+ return command unless command[:mid_turn] == :loop && @engine.command_registry.mid_turn(command[:line]) == :queue
645
+
646
+ event = { type: :command_queued, **command.slice(:command_id, :client_id, :line), waits: "turn_end" }
647
+ event[:card] = true if command[:card]
648
+ @engine.announce(event)
649
+ command.merge(mid_turn: :queue)
650
+ end
651
+
652
+ def shell_command?(command) = @engine.command_registry.lookup(command[:line])&.id == :shell
653
+
654
+ def busy_output(command)
655
+ @engine.command_registry.lookup(command[:line])&.id == :rollback ? ROLLBACK_BUSY_OUTPUT : BUSY_OUTPUT
656
+ end
657
+
658
+ # +command+ won't run: its sender (a UI's queued bubble) is told.
659
+ def drop_command(command, output)
660
+ announce_command(command, status: "dropped", output: output, changed: [])
661
+ end
662
+
663
+ # The worker leaves with commands still queued (a stop, a crash): each
664
+ # one's sender is told it won't run, so no UI shows it waiting for good.
665
+ def drop_queued_commands
666
+ return if @command_queue.nil? || @command_queue.empty? || @engine.nil?
667
+
668
+ @engine.synchronize_events do
669
+ drain_commands.each { |command| drop_command(command, "dropped: the session's worker stopped before it ran") }
670
+ end
671
+ rescue StandardError => e
672
+ Log.warn(:worker, "drop_commands_failed", error: e.class.name, msg: e.message)
575
673
  end
576
674
 
577
675
  def next_command
@@ -607,7 +705,44 @@ module Samagotchi
607
705
  [result || SessionCommands::Result.new(status: :error, output: "not a session command", changed: []), shown]
608
706
  end
609
707
 
610
- def anytime_command?(line) = @engine.command_registry.lookup(line)&.anytime == true
708
+ # A session command from the Bridge, called with the event log held:
709
+ # an anytime one starts now, any other is queued for the loop (and
710
+ # refused if a turn runs: #refuse_queued_commands). While a turn runs
711
+ # the line's policy decides (a show form like /model runs now); idle,
712
+ # only an always-anytime command (/help) runs beside the loop, so an
713
+ # idle /model X then /model run in order. A turn starting or ending
714
+ # right now may see the other side: the line is only shown early or
715
+ # run in order.
716
+ #
717
+ # A queued one (:queue) keeps arrival order with the prompts (D1): it
718
+ # runs once every input file written before it is taken (after_file,
719
+ # the newest one now; names compare, not clocks), so a prompt sent
720
+ # before it runs first and one sent after it runs after. One that came
721
+ # in an input file runs once that file is taken (+after_file+).
722
+ # @return [Symbol] the policy it applied (the Bridge marks an anytime
723
+ # one's command_queued, and a queued one's): :anytime, :queue or
724
+ # :refuse while a turn runs, :loop idle
725
+ def on_command(command, after_file: nil)
726
+ registry = @engine.command_registry
727
+ policy = if @engine.turn_running? then registry.mid_turn(command[:line])
728
+ else registry.anytime?(command[:line]) ? :anytime : :loop
729
+ end
730
+ if policy == :anytime
731
+ start_anytime_command(command)
732
+ return policy
733
+ end
734
+
735
+ after_file ||= newest_input_file if policy == :queue
736
+ # The count says which turn ends came before it.
737
+ @command_queue << command.merge(after_seq: @engine.event_count, mid_turn: policy, after_file: after_file).compact
738
+ @waker.wake
739
+ policy
740
+ end
741
+
742
+ # @return [String, nil] the newest input file waiting (its basename)
743
+ def newest_input_file
744
+ SessionInbox.find_new_input_files(@session_dir).map { |path| File.basename(path) }.max
745
+ end
611
746
 
612
747
  # An anytime command (/help, a plugin's /btw; D8) runs now, on its own
613
748
  # thread, never queued behind a turn: it is never busy. The Bridge calls
@@ -640,6 +775,8 @@ module Samagotchi
640
775
  status: status, output: text[0, COMMAND_OUTPUT_LIMIT], changed: changed.map(&:to_s),
641
776
  model_name: @engine.effective_model_name }
642
777
  event[:anytime] = true if anytime
778
+ # Its line was shown at its command_queued (waits: turn_end).
779
+ event[:queued] = true if command[:mid_turn] == :queue
643
780
  event[:card] = true if command[:card]
644
781
  # /llm-context or /model: what the next turn runs under now (the web's chip).
645
782
  event[:llm_context] = @engine.llm_context_summary if changed.intersect?(%i[llm_context model])
@@ -694,7 +831,7 @@ module Samagotchi
694
831
  # failed prompt or wake turn that got nowhere, a canceled continue
695
832
  # turn). A failed turn whose work stayed, and a reminder or continue
696
833
  # turn that failed (kept with its failed note), read them.
697
- settle_child_reports(rolled_back: @turn_flow.restores != restores)
834
+ settle_child_reports(rolled_back: @turn_flow.restores != restores || merges_lost?(error))
698
835
  ring_parent_after_turn
699
836
  end
700
837
 
@@ -707,6 +844,20 @@ module Samagotchi
707
844
  rolled_back ? @child_reports.release : @child_reports.commit
708
845
  end
709
846
 
847
+ # Whether +error+ ended the turn without its loop's conversation: a
848
+ # failure the loops didn't mark (LLM::FailedTurn), one past their end
849
+ # (the Engine's own end of the turn, say). The Engine kept the turn's
850
+ # start only, so the reports its loop merged are not in the
851
+ # conversation: their rings stay for the next turn, even when the turn
852
+ # itself stays (a reminder or continue turn). Not one raised after the
853
+ # turn ended (the Engine's post-turn work): the turn was kept as
854
+ # completed, the reports in it.
855
+ def merges_lost?(error)
856
+ return false if error.nil? || error.is_a?(LLM::FailedTurn)
857
+
858
+ @session.last_turn&.dig("outcome") != "completed"
859
+ end
860
+
710
861
  # A delegate child's turn that was its parent's (#reports_to_parent?)
711
862
  # rings the parent, after the save: the parent reads a settled child.
712
863
  # A question it rang about that still waits makes the next turn ring
@@ -61,8 +61,9 @@ module Samagotchi
61
61
  # @param wakes [WorkerWakes] the wake budget a context wake asks
62
62
  # @param awaiting_continue [#call] whether a continue offer waits
63
63
  # @param stopped [#call] whether the session was stopped on disk
64
- # @param queue_command [#call] (line, client_id) queues a session
65
- # command for the loop's next pass
64
+ # @param queue_command [#call] (line, client_id, after_file) queues a
65
+ # session command for the loop's next pass; after_file is the input
66
+ # file it came in, or nil
66
67
  def initialize(session:, state_dir:, session_dir:, engine:, context_absorber:, wakes:, awaiting_continue:,
67
68
  stopped:, queue_command:)
68
69
  @session = session
@@ -182,7 +183,7 @@ module Samagotchi
182
183
  begin
183
184
  message, origin, no_interrupt, images = SessionInbox.read_input(claimed_file)
184
185
  return if message.to_s.strip.empty?
185
- return if Array(images).empty? && queue_as_command(message, origin)
186
+ return if Array(images).empty? && queue_as_command(message, origin, File.basename(input_file))
186
187
 
187
188
  yield Prompt.new(text: message, origin: origin, no_interrupt: !!no_interrupt, images: images || [])
188
189
  ensure
@@ -211,10 +212,10 @@ module Samagotchi
211
212
  # POST /turn; an unknown /word stays a prompt. Queued: the loop's next
212
213
  # pass runs it.
213
214
  # @return [Boolean] whether +text+ was one
214
- def queue_as_command(text, origin)
215
+ def queue_as_command(text, origin, after_file = nil)
215
216
  return false unless @engine.command_registry.command?(text.to_s)
216
217
 
217
- @queue_command.call(text, origin&.dig(:client_id))
218
+ @queue_command.call(text, origin&.dig(:client_id), after_file)
218
219
  true
219
220
  end
220
221
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.44.0
4
+ version: 0.46.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -136,6 +136,7 @@ files:
136
136
  - docs/internals/gemma4-contract.md
137
137
  - docs/internals/llm-context-bench.md
138
138
  - docs/internals/llm-context-forget.md
139
+ - docs/internals/llm-context-live.md
139
140
  - docs/internals/prompt-caching.md
140
141
  - docs/internals/tool-guardrails.md
141
142
  - docs/memory.md
@@ -285,6 +286,7 @@ files:
285
286
  - lib/samagotchi/hooks/loader.rb
286
287
  - lib/samagotchi/hooks/registry.rb
287
288
  - lib/samagotchi/hooks/stream_watch.rb
289
+ - lib/samagotchi/host_model.rb
288
290
  - lib/samagotchi/host_registry.rb
289
291
  - lib/samagotchi/idle_client.rb
290
292
  - lib/samagotchi/idle_recap.rb
@@ -348,6 +350,7 @@ files:
348
350
  - lib/samagotchi/model_match.rb
349
351
  - lib/samagotchi/model_notes.rb
350
352
  - lib/samagotchi/model_overlay.rb
353
+ - lib/samagotchi/model_price.rb
351
354
  - lib/samagotchi/model_profile.rb
352
355
  - lib/samagotchi/model_ref.rb
353
356
  - lib/samagotchi/models_command.rb