samagotchi 0.9.0 → 0.10.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/docs/architecture.md +24 -9
  4. data/docs/cli.md +4 -3
  5. data/docs/guardrails.md +3 -2
  6. data/docs/hooks.md +44 -7
  7. data/docs/internals/gemma4-contract.md +1 -0
  8. data/docs/plugins.md +34 -12
  9. data/docs/sessions.md +1 -1
  10. data/lib/samagotchi/bootstrap_command.rb +29 -29
  11. data/lib/samagotchi/bridge/card_store.rb +4 -4
  12. data/lib/samagotchi/bridge/pending_card.rb +2 -2
  13. data/lib/samagotchi/bridge/sse_writer.rb +22 -28
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +92 -5
  15. data/lib/samagotchi/bridge.rb +59 -112
  16. data/lib/samagotchi/bridge_client.rb +11 -28
  17. data/lib/samagotchi/bundle_command.rb +158 -212
  18. data/lib/samagotchi/bundles/check-in/manifest.yml +2 -2
  19. data/lib/samagotchi/bundles/check-in/plugin.rb +3 -4
  20. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +7 -3
  21. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/loop-guard/plugin.rb +10 -10
  24. data/lib/samagotchi/bundles/mcp/manifest.yml +3 -3
  25. data/lib/samagotchi/bundles/mcp/plugin.rb +2 -4
  26. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  27. data/lib/samagotchi/bundles/skills/plugin.rb +4 -6
  28. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +0 -1
  29. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  30. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  31. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  32. data/lib/samagotchi/cli/command.rb +51 -0
  33. data/lib/samagotchi/cli/flags.rb +116 -0
  34. data/lib/samagotchi/config.rb +70 -55
  35. data/lib/samagotchi/config_text_edit.rb +107 -0
  36. data/lib/samagotchi/desktop_command.rb +24 -18
  37. data/lib/samagotchi/edit_preview.rb +2 -2
  38. data/lib/samagotchi/engine.rb +20 -75
  39. data/lib/samagotchi/events.rb +23 -0
  40. data/lib/samagotchi/guardrail_wiring.rb +2 -5
  41. data/lib/samagotchi/hooks/bundle_loader.rb +13 -30
  42. data/lib/samagotchi/hooks/loader.rb +23 -33
  43. data/lib/samagotchi/hooks/registry.rb +41 -0
  44. data/lib/samagotchi/hooks.rb +0 -3
  45. data/lib/samagotchi/host_registry.rb +15 -0
  46. data/lib/samagotchi/idle_reminders.rb +0 -7
  47. data/lib/samagotchi/image_store.rb +22 -0
  48. data/lib/samagotchi/kernel_loop.rb +12 -16
  49. data/lib/samagotchi/live_versions.rb +2 -13
  50. data/lib/samagotchi/llm/native_tool_normalizer.rb +10 -227
  51. data/lib/samagotchi/memory_bundle/builder.rb +1 -14
  52. data/lib/samagotchi/memory_bundle/index_updater.rb +2 -28
  53. data/lib/samagotchi/memory_bundle/installer.rb +1 -27
  54. data/lib/samagotchi/memory_bundle/provenance.rb +2 -6
  55. data/lib/samagotchi/memory_bundle/status.rb +1 -14
  56. data/lib/samagotchi/memory_bundle/system_bundle.rb +2 -2
  57. data/lib/samagotchi/memory_bundle/uninstaller.rb +1 -24
  58. data/lib/samagotchi/memory_paths.rb +10 -0
  59. data/lib/samagotchi/note_command.rb +23 -37
  60. data/lib/samagotchi/owner_lock.rb +14 -3
  61. data/lib/samagotchi/plugin/api.rb +11 -17
  62. data/lib/samagotchi/plugin/context.rb +70 -15
  63. data/lib/samagotchi/plugin/loader.rb +2 -3
  64. data/lib/samagotchi/sampling_settings.rb +3 -5
  65. data/lib/samagotchi/self_report.rb +6 -9
  66. data/lib/samagotchi/send_command.rb +33 -52
  67. data/lib/samagotchi/session.rb +88 -92
  68. data/lib/samagotchi/session_archive_command.rb +8 -14
  69. data/lib/samagotchi/session_commands.rb +3 -21
  70. data/lib/samagotchi/session_delete_command.rb +15 -29
  71. data/lib/samagotchi/session_manager.rb +27 -13
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +30 -80
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +1 -10
  74. data/lib/samagotchi/terminal_ui/between_turns.rb +133 -0
  75. data/lib/samagotchi/terminal_ui/event_renderer.rb +3 -1
  76. data/lib/samagotchi/terminal_ui/formatting.rb +51 -1
  77. data/lib/samagotchi/terminal_ui/status_row.rb +32 -0
  78. data/lib/samagotchi/terminal_ui/thinking_line.rb +3 -3
  79. data/lib/samagotchi/terminal_ui.rb +156 -234
  80. data/lib/samagotchi/thinking.rb +2 -3
  81. data/lib/samagotchi/thought_stream_splitter.rb +10 -9
  82. data/lib/samagotchi/tool_call_parser.rb +56 -292
  83. data/lib/samagotchi/tools/builtin_calls.rb +146 -0
  84. data/lib/samagotchi/tools/builtins.rb +2 -1
  85. data/lib/samagotchi/tools/edit.rb +21 -29
  86. data/lib/samagotchi/tools/memory.rb +1 -11
  87. data/lib/samagotchi/tools/send_note.rb +1 -1
  88. data/lib/samagotchi/update_command.rb +32 -22
  89. data/lib/samagotchi/version.rb +1 -1
  90. data/lib/samagotchi/vision_support.rb +4 -5
  91. data/lib/samagotchi/web/app.rb +89 -151
  92. data/lib/samagotchi/web/public/activity.js +1 -11
  93. data/lib/samagotchi/web/public/annotate_bar.js +120 -0
  94. data/lib/samagotchi/web/public/app.js +158 -785
  95. data/lib/samagotchi/web/public/chunk_router.js +7 -6
  96. data/lib/samagotchi/web/public/command_list.js +99 -0
  97. data/lib/samagotchi/web/public/data.js +22 -66
  98. data/lib/samagotchi/web/public/format.js +0 -23
  99. data/lib/samagotchi/web/public/index.html +0 -2
  100. data/lib/samagotchi/web/public/model_pick.js +0 -7
  101. data/lib/samagotchi/web/public/notifications.js +169 -0
  102. data/lib/samagotchi/web/public/question_cards.js +352 -0
  103. data/lib/samagotchi/web/public/session_state.js +34 -0
  104. data/lib/samagotchi/web/public/timing.js +1 -1
  105. data/lib/samagotchi/web/public/turn_events.js +11 -2
  106. data/lib/samagotchi/web/public/turn_model.js +0 -14
  107. data/lib/samagotchi/web/session_hub.rb +6 -4
  108. data/lib/samagotchi/web/session_summary.rb +5 -3
  109. data/lib/samagotchi/worker.rb +59 -53
  110. data/lib/samagotchi/worker_sidecar.rb +27 -1
  111. metadata +12 -1
@@ -21,6 +21,7 @@ require_relative "output_formatter"
21
21
  require_relative "turn_flow"
22
22
  require_relative "session_commands"
23
23
  require_relative "terminal_ui/attached_view"
24
+ require_relative "terminal_ui/between_turns"
24
25
  require_relative "terminal_ui/event_renderer"
25
26
  require_relative "terminal_ui/formatting"
26
27
  require_relative "terminal_ui/input_support"
@@ -63,6 +64,14 @@ module Samagotchi
63
64
  # --resume with an id that has no saved session.
64
65
  class SessionNotFound < StandardError; end
65
66
 
67
+ # What prints between turns (cards, notices, init lines, an idle recap):
68
+ # announced on other threads, printed at the open prompt.
69
+ # @return [BetweenTurns]
70
+ attr_reader :between_turns
71
+ # The session's Engine: the REPL drives it inline (#run_engine_turn).
72
+ # @return [Engine]
73
+ attr_reader :engine
74
+
66
75
  # ── System prompts (delegated to Engine) ─────────────────────────────────
67
76
  def self.system_prompt_for(profile)
68
77
  Engine.system_prompt_for(profile)
@@ -117,12 +126,6 @@ module Samagotchi
117
126
  @requested_memories = (@resume_session.preloaded_memory_names + @requested_memories).uniq
118
127
  @muted_memory_names = (@resume_session.muted_memory_names + @muted_memory_names).uniq
119
128
  end
120
- # A recap written while idle, printed by the main thread at the open
121
- # prompt (#flush_pending_recap).
122
- @pending_recap = nil
123
- # Cards and notices announced between turns, printed by the main
124
- # thread at the open prompt (#flush_pending_cards).
125
- @pending_cards = Queue.new
126
129
  # Every terminal write goes through the surface. The REPL swaps in a
127
130
  # live region when the terminal can show one (#assist_loop), unless it
128
131
  # was given a surface to draw on.
@@ -137,6 +140,10 @@ module Samagotchi
137
140
  @view = AttachedView.new(@surface, **view_options)
138
141
  @status_row = StatusRow.new(@surface)
139
142
  @renderer = EventRenderer.new(@view)
143
+ # Cards, notices and a recap announced between turns, printed by the
144
+ # main thread at the open prompt (#poll_input_with_reminder_check).
145
+ @between_turns = BetweenTurns.new(surface: @surface, view: @view, renderer: @renderer,
146
+ turn_running: -> { @engine.turn_running? }, quiet: @non_interactive)
140
147
  @render_event = ->(event) { handle_stream_event(event) }
141
148
  @engine = Engine.new(
142
149
  client: client,
@@ -176,9 +183,9 @@ module Samagotchi
176
183
  @engine.switch_model!(@effective_model_name) if @effective_model_name != @default_model_name
177
184
  # Render an idle session-recap via the cursor-safe background writer; the
178
185
  # detector itself is Engine-owned (see Engine#recap) and opt-in.
179
- @recap_handle = @engine.subscribe(observer: ->(event) { handle_recap_ready(event) })
186
+ @recap_handle = @engine.subscribe(observer: ->(event) { @between_turns.take_recap(event) })
180
187
  @question_handle = @engine.subscribe(observer: ->(event) { handle_question_event(event) })
181
- @card_handle = @engine.subscribe(observer: ->(event) { handle_card_event(event) })
188
+ @card_handle = @engine.subscribe(observer: ->(event) { @between_turns.observe(event) })
182
189
  # The REPL renders events from here on: the load warnings and notices
183
190
  # now (at the first prompt), and the plugins' slow setup in the
184
191
  # background. A --non-interactive run leaves both to its turn.
@@ -251,16 +258,12 @@ module Samagotchi
251
258
 
252
259
  assist_loop(session: session, messages: messages)
253
260
  # After the idle layer has stopped: nothing writes the session now.
254
- if @scratch
255
- nil # deleted below, however the REPL ended
256
- elsif @delete_on_exit
257
- delete_after_exit(session)
258
- elsif @discard_on_exit
259
- discard_after_exit(session)
260
- elsif @archive_on_exit
261
- archive_after_exit(session)
262
- else
263
- keep_after_exit(session)
261
+ case exit_action
262
+ when :scratch then nil # deleted below, however the REPL ended
263
+ when :delete then delete_after_exit(session)
264
+ when :discard then discard_after_exit(session)
265
+ when :archive then archive_after_exit(session)
266
+ else keep_after_exit(session)
264
267
  end
265
268
  # Lines from a pipe (`chi -p X </dev/null`): a failed turn fails the run.
266
269
  :turn_failed if @piped_turn_failed
@@ -360,9 +363,9 @@ module Samagotchi
360
363
  @owner_lock = OwnerLock.acquire(session_dir, kind: "tui", wait: 1.0)
361
364
  return @owner_lock if @owner_lock
362
365
 
363
- owner = OwnerLock.owner(session_dir) || {}
364
- where = owner["kind"] == "tui" ? "another chi" : "a `chi web` worker"
365
- raise SessionBusy, "Session #{session_id} is open in #{where} (pid #{owner["pid"] || "unknown"}). " \
366
+ owner = OwnerLock.owner(session_dir)
367
+ where = owner&.tui? ? "another chi" : "a `chi web` worker"
368
+ raise SessionBusy, "Session #{session_id} is open in #{where} (pid #{owner&.pid || "unknown"}). " \
366
369
  "Close it there first."
367
370
  end
368
371
 
@@ -388,6 +391,124 @@ module Samagotchi
388
391
  messages
389
392
  end
390
393
 
394
+ # Interactive REPL loop. Session seed + messages are built by #run and
395
+ # threaded in here (so --prompt / --resume share one code path). The
396
+ # session's conversation is the single working copy: turns go through
397
+ # Engine#run_turn and out-of-turn edits through Engine's messages API.
398
+ # The checkpoint and continue state live in @turn_flow (shared with
399
+ # session workers). The session is persisted at the end of every turn.
400
+ def run_assist_loop(session:, messages:)
401
+ session.messages = messages
402
+ @engine.session = session
403
+ # Steering: lines submitted at the open prompt during a turn
404
+ # (#steer_line), merged by the kernel (#drain_steering).
405
+ @pending_input_queue = PendingInputQueue.new
406
+ open_repl_input
407
+
408
+ loop do
409
+ if @exit_after_turn
410
+ # Ctrl-D or exit came during a turn: the lines sent before it still
411
+ # run (all queued: the prompt closed at Ctrl-D), then the REPL ends.
412
+ input = queued_line
413
+ break if input.nil?
414
+ else
415
+ # Drain any pending ask_user_question first — it has priority over reminders and
416
+ # must be rendered on the REPL thread (turn thread is parked on Engine Monitor).
417
+ drain_pending_question?
418
+
419
+ # A due reminder runs its turn now, with the prompt open (on a
420
+ # terminal): the turn's output commits above it and whatever is typed
421
+ # there stays.
422
+ unless @engine.due_reminder_names.empty?
423
+ run_reminder_turn(session)
424
+ @turn_flow.after_reminder_turn
425
+ next
426
+ end
427
+ input = @prompt
428
+ @prompt = nil if input
429
+ if input.nil?
430
+ input = poll_input_with_reminder_check(awaiting_continue: @turn_flow.awaiting_continue?)
431
+ # A reminder fell due: run it at the top, with the prompt still open.
432
+ next if input == :due
433
+ end
434
+ end
435
+ break if input.nil?
436
+ local = local_command(input)
437
+ if local == :exit
438
+ @delete_on_exit ||= SessionCommands.delete_on_exit?(input)
439
+ break
440
+ end
441
+ if local == :archive
442
+ next @surface.commit(SCRATCH_ARCHIVE_REFUSED) if @scratch
443
+
444
+ @archive_on_exit = true
445
+ break
446
+ end
447
+ # Not an answer to a continue offer either.
448
+ next detach_note if local == :detach
449
+
450
+ # /stats and /recap run; the offer stays open.
451
+ if @turn_flow.awaiting_continue? && !%i[stats recap].include?(local)
452
+ answer_continue_offer(session, input)
453
+ else
454
+ # The ? read left no echo: show what ran.
455
+ @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}") if @turn_flow.awaiting_continue?
456
+ run_input_line(session, input)
457
+ end
458
+ end
459
+
460
+ close_repl_input
461
+ @discard_on_exit = !@delete_on_exit && discard_on_exit?(session)
462
+ end
463
+
464
+ # Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
465
+ # Engine#run_turn, rendering via @renderer.
466
+ # @param images [Array<Hash>] the prompt's `@path` images ({path:})
467
+ def run_engine_turn(session, prompt, continue: false, max_iterations: 100, images: [])
468
+ cancellation_controller = CancellationController.new
469
+ @active_cancel_controller = cancellation_controller
470
+ @renderer.begin_turn
471
+ # The row shows the turn's model (a -p turn runs before any read).
472
+ refresh_status_row
473
+ # A prompt the user typed brings an archived session back to the lists.
474
+ ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
475
+ with_steering do
476
+ @engine.run_turn(
477
+ session,
478
+ prompt,
479
+ on_event: @render_event,
480
+ max_iterations: max_iterations,
481
+ cancel_controller: cancellation_controller,
482
+ pending_input: method(:drain_steering),
483
+ continue: continue,
484
+ images: images
485
+ )
486
+ end
487
+ rescue Interrupt
488
+ # Engine kept the prompt in the session and emitted :turn_canceled
489
+ # (the renderer's line).
490
+ cancellation_controller&.cancel!(:ctrl_c)
491
+ cancelled_result_from(session.messages, reason: :ctrl_c)
492
+ ensure
493
+ @active_cancel_controller = nil
494
+ @view.finish_thinking_spinner
495
+ end
496
+
497
+ # How the REPL leaves its session once the loop ended (#run): :scratch
498
+ # (deleted however it ends), :delete (/exit --delete), :discard (left
499
+ # empty), :archive (/archive) or :keep.
500
+ def exit_action
501
+ if @scratch then :scratch
502
+ elsif @delete_on_exit then :delete
503
+ elsif @discard_on_exit then :discard
504
+ elsif @archive_on_exit then :archive
505
+ else :keep
506
+ end
507
+ end
508
+
509
+ # Ctrl-D or /exit came during a turn: the REPL ends after it.
510
+ def exit_after_turn? = @exit_after_turn == true
511
+
391
512
  private
392
513
 
393
514
  # Interactive REPL loop. Session seed + messages are built by #run and
@@ -468,76 +589,7 @@ module Samagotchi
468
589
  @surface = surface
469
590
  @view.surface = surface
470
591
  @status_row.surface = surface
471
- end
472
-
473
- # Interactive REPL loop. Session seed + messages are built by #run and
474
- # threaded in here (so --prompt / --resume share one code path). The
475
- # session's conversation is the single working copy: turns go through
476
- # Engine#run_turn and out-of-turn edits through Engine's messages API.
477
- # The checkpoint and continue state live in @turn_flow (shared with
478
- # session workers). The session is persisted at the end of every turn.
479
- def run_assist_loop(session:, messages:)
480
- session.messages = messages
481
- @engine.session = session
482
- # Steering: lines submitted at the open prompt during a turn
483
- # (#steer_line), merged by the kernel (#drain_steering).
484
- @pending_input_queue = PendingInputQueue.new
485
- open_repl_input
486
-
487
- loop do
488
- if @exit_after_turn
489
- # Ctrl-D or exit came during a turn: the lines sent before it still
490
- # run (all queued: the prompt closed at Ctrl-D), then the REPL ends.
491
- input = queued_line
492
- break if input.nil?
493
- else
494
- # Drain any pending ask_user_question first — it has priority over reminders and
495
- # must be rendered on the REPL thread (turn thread is parked on Engine Monitor).
496
- drain_pending_question?
497
-
498
- # A due reminder runs its turn now, with the prompt open (on a
499
- # terminal): the turn's output commits above it and whatever is typed
500
- # there stays.
501
- unless @engine.due_reminder_names.empty?
502
- run_reminder_turn(session)
503
- @turn_flow.after_reminder_turn
504
- next
505
- end
506
- input = @prompt
507
- @prompt = nil if input
508
- if input.nil?
509
- input = poll_input_with_reminder_check(awaiting_continue: @turn_flow.awaiting_continue?)
510
- # A reminder fell due: run it at the top, with the prompt still open.
511
- next if input == :due
512
- end
513
- end
514
- break if input.nil?
515
- local = local_command(input)
516
- if local == :exit
517
- @delete_on_exit ||= SessionCommands.delete_on_exit?(input)
518
- break
519
- end
520
- if local == :archive
521
- next @surface.commit(SCRATCH_ARCHIVE_REFUSED) if @scratch
522
-
523
- @archive_on_exit = true
524
- break
525
- end
526
- # Not an answer to a continue offer either.
527
- next detach_note if local == :detach
528
-
529
- # /stats and /recap run; the offer stays open.
530
- if @turn_flow.awaiting_continue? && !%i[stats recap].include?(local)
531
- answer_continue_offer(session, input)
532
- else
533
- # The ? read left no echo: show what ran.
534
- @surface.commit("#{paint(QUESTION_PROMPT, 33)}#{input}") if @turn_flow.awaiting_continue?
535
- run_input_line(session, input)
536
- end
537
- end
538
-
539
- close_repl_input
540
- @discard_on_exit = !@delete_on_exit && discard_on_exit?(session)
592
+ @between_turns.surface = surface
541
593
  end
542
594
 
543
595
  # An answer at the ? prompt of a continue offer. A valid one closes the
@@ -693,8 +745,8 @@ module Samagotchi
693
745
  sync_continue_slot(false)
694
746
  @surface.commit(QuestionSlot.continue_summary("(dropped: a reminder ran)", paint: method(:paint)))
695
747
  end
696
- @surface.commit("reminder: #{names.join(", ")}")
697
- hint = "reminder: #{names.join(", ")} · Ctrl-C cancels it"
748
+ @surface.commit(reminder_line(names))
749
+ hint = "#{reminder_line(names)} · Ctrl-C cancels it"
698
750
  @surface.set_slot(:hints, [color_output? ? paint(hint, 90) : hint])
699
751
  result = nil
700
752
  begin
@@ -725,7 +777,7 @@ module Samagotchi
725
777
  return :due unless @engine.due_reminder_names.empty?
726
778
  # Specs and pipes: a plain blocking read.
727
779
  unless @repl_input
728
- flush_pending_cards
780
+ @between_turns.flush_cards
729
781
  return read_input(awaiting_continue: awaiting_continue)
730
782
  end
731
783
 
@@ -736,8 +788,8 @@ module Samagotchi
736
788
  sync_continue_slot(awaiting_continue)
737
789
  @repl_input.sync_prompt
738
790
  loop do
739
- flush_pending_recap
740
- flush_pending_cards
791
+ @between_turns.flush_recap
792
+ @between_turns.flush_cards
741
793
  kind, line = @repl_input.pop(timeout: REMINDER_PENDING_POLL_INTERVAL)
742
794
  if kind
743
795
  # What the line does may change the status (/model, a turn).
@@ -826,7 +878,7 @@ module Samagotchi
826
878
  end
827
879
 
828
880
  # /recap: the saved recap, and a new one asked for at once when the
829
- # chat moved on (it prints when it arrives, #flush_pending_recap).
881
+ # chat moved on (it prints when it arrives, BetweenTurns#flush_recap).
830
882
  def handle_recap_command
831
883
  recap = @engine.recap
832
884
  return recap_command_text(enabled: false) unless recap
@@ -843,39 +895,6 @@ module Samagotchi
843
895
  Array(messages).map(&:dup)
844
896
  end
845
897
 
846
- # Run one REPL turn (a prompt, or a continue/reminder turn with nil) through
847
- # Engine#run_turn, rendering via @renderer.
848
- # @param images [Array<Hash>] the prompt's `@path` images ({path:})
849
- def run_engine_turn(session, prompt, continue: false, max_iterations: 100, images: [])
850
- cancellation_controller = CancellationController.new
851
- @active_cancel_controller = cancellation_controller
852
- @renderer.begin_turn
853
- # The row shows the turn's model (a -p turn runs before any read).
854
- refresh_status_row
855
- # A prompt the user typed brings an archived session back to the lists.
856
- ArchiveStore.user_input(session.id, state_dir: Session.default_state_dir) if prompt && !continue
857
- with_steering do
858
- @engine.run_turn(
859
- session,
860
- prompt,
861
- on_event: @render_event,
862
- max_iterations: max_iterations,
863
- cancel_controller: cancellation_controller,
864
- pending_input: method(:drain_steering),
865
- continue: continue,
866
- images: images
867
- )
868
- end
869
- rescue Interrupt
870
- # Engine kept the prompt in the session and emitted :turn_canceled
871
- # (the renderer's line).
872
- cancellation_controller&.cancel!(:ctrl_c)
873
- cancelled_result_from(session.messages, reason: :ctrl_c)
874
- ensure
875
- @active_cancel_controller = nil
876
- @view.finish_thinking_spinner
877
- end
878
-
879
898
  # While a turn runs, a line submitted at the open prompt steers it: it
880
899
  # merges at the next iteration boundary (Kernel), and one that comes after
881
900
  # the last runs as the next turn. Reminder turns too.
@@ -942,16 +961,15 @@ module Samagotchi
942
961
  # D8: an anytime command runs on its own thread while the turn goes on
943
962
  # (it reads copies, and shows things through its ctx). What it prints
944
963
  # while the turn runs goes above the live region now, its cards as it
945
- # shows them (#handle_card_event); once the turn has ended it waits for
964
+ # shows them (BetweenTurns#observe); once the turn has ended it waits for
946
965
  # the prompt's flush.
947
966
  # @return [true]
948
967
  def start_anytime_command(line)
949
968
  @engine.spawn_anytime do
950
969
  output = @engine.running_anytime { @commands.run(line) }&.output
951
- items = output.nil? ? [] : [{ type: :command_output, text: "\nmodel> #{output}" }]
952
- items.each { |item| @engine.turn_running? ? show_pending_item(item) : @pending_cards << item }
970
+ @between_turns.show_or_keep({ type: :command_output, text: "\nmodel> #{output}" }) unless output.nil?
953
971
  rescue StandardError => e
954
- @pending_cards << { type: :command_output, text: "\nmodel> #{line.split.first}: #{e.message}" }
972
+ @between_turns.keep({ type: :command_output, text: "\nmodel> #{line.split.first}: #{e.message}" })
955
973
  end
956
974
  true
957
975
  end
@@ -970,13 +988,7 @@ module Samagotchi
970
988
  # Bridge's). Engine swallows on_event errors to protect the turn, so log
971
989
  # ours instead.
972
990
  def handle_stream_event(event)
973
- case event[:type]
974
- when :context_status
975
- @status_row.update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
976
- when :used_memories_updated then @status_row.update(used_memories: Array(event[:used_memory_names]))
977
- when :generation_completed
978
- @status_row.update(served: [event[:served_model], event[:requested_model]]) if event[:served_model]
979
- end
991
+ @status_row.take_event(event)
980
992
  @renderer.call(event)
981
993
  rescue StandardError => e
982
994
  Log.error(:repl, "render_failed", echo: "[render] #{event[:type]}: #{e.class}: #{e.message}", event_type: event[:type].to_s, error: e.class.name)
@@ -997,98 +1009,8 @@ module Samagotchi
997
1009
  # The status row under the prompt, from the Engine's state (what a
998
1010
  # worker hands an attached TUI when it joins): drawn when it changed.
999
1011
  def refresh_status_row
1000
- state = @engine.session_state_snapshot
1001
- served = state[:served_model] ? [state[:served_model], state[:served_model_for]] : nil
1002
- fields = { model: @effective_model_name, default_model: @default_model_name, served: served,
1003
- parent_id: state[:parent_id], used_memories: Array(state[:used_memory_names]),
1004
- preloaded: Array(state[:preloaded_memory_names]), muted: Array(state[:muted_memory_names]) }
1005
- fields[:context] = state[:context_status] if state[:context_status].is_a?(Hash)
1006
- @status_row.update(**fields)
1007
- end
1008
-
1009
- # ── Idle session recap ───────────────────────────────────────────────────
1010
- #
1011
- # The Engine's idle job emits :recap_ready once the session has been idle
1012
- # for its inactivity threshold (or /recap asked). On the scheduler thread:
1013
- # kept for the main thread to print at the open prompt. One collected
1014
- # just as a turn started describes the chat before it.
1015
- def handle_recap_ready(event)
1016
- return unless event[:type] == :recap_ready
1017
- return if event[:recap].to_s.strip.empty? || @engine.turn_running?
1018
-
1019
- @pending_recap = event[:recap].to_s
1020
- end
1021
-
1022
- # Print a recap written while idle (main thread, at the open prompt).
1023
- def flush_pending_recap
1024
- recap = @pending_recap
1025
- return unless recap
1026
-
1027
- @pending_recap = nil
1028
- @surface.commit(recap_block(recap))
1029
- end
1030
-
1031
- # ── Cards and notices between turns ─────────────────────────────────────
1032
- #
1033
- # A card or a plugin's notice shown outside a turn is announced; on the
1034
- # announcing thread it is only kept, for the main thread to print at the
1035
- # open prompt (after the command that showed it). One shown during a
1036
- # turn is a turn event: the turn's sink prints it where it happens
1037
- # (EventRenderer; the Screen draws it above the live region).
1038
- #
1039
- # An anytime command's (event[:anytime]) print as it shows them: on the
1040
- # main thread (the command runs at the prompt) or beside a running turn
1041
- # (above the live region), else at the next flush.
1042
- #
1043
- # A plugin's init task (chi.init) turns the activity row while it runs
1044
- # (between turns too, as in attached mode) and prints a line when it is
1045
- # done; a load warning announced before the first turn, one. Beside a
1046
- # running turn they print at once; a --non-interactive run prints only
1047
- # its answer.
1048
- def handle_card_event(event)
1049
- if INIT_EVENTS.include?(event[:type])
1050
- return if @non_interactive
1051
- return @view.init_started(event) if event[:type] == :plugin_init_started
1052
-
1053
- @view.init_finished(event) if event[:type] == :plugin_init_finished
1054
- return show_pending_item(event) if @engine.turn_running?
1055
-
1056
- return @pending_cards << event
1057
- end
1058
- return unless (event[:type] == :card && !event[:in_turn]) || (event[:type] == :hook_notice && event[:between_turns])
1059
- return show_pending_item(event) if event[:anytime] && (Thread.current == Thread.main || @engine.turn_running?)
1060
-
1061
- @pending_cards << event
1062
- end
1063
-
1064
- # Print the cards, notices and anytime commands' output kept since the
1065
- # last flush (main thread). A card replaced later in the same batch (the
1066
- # same id: btw's "thinking…", then its answer) prints once, as its last.
1067
- def flush_pending_cards
1068
- items = []
1069
- loop { items << @pending_cards.pop(true) }
1070
- rescue ThreadError
1071
- items.each_with_index do |item, index|
1072
- replaced = item[:type] == :card && items.drop(index + 1).any? { |later| later[:type] == :card && later[:id] == item[:id] }
1073
- show_pending_item(item) unless replaced
1074
- end
1075
- nil
1076
- end
1077
-
1078
- INIT_EVENTS = %i[plugin_init_started plugin_init_finished guardrail_warning].freeze
1079
-
1080
- # A card, a notice, an anytime command's output (:command_output), a
1081
- # plugin init task's done line or a load warning.
1082
- def show_pending_item(item)
1083
- case item[:type]
1084
- when :card then @renderer.render_card(item)
1085
- when :command_output then @surface.commit(item[:text])
1086
- when :guardrail_warning then @surface.commit(EventRenderer.load_warning_line(item))
1087
- when :plugin_init_finished
1088
- line = EventRenderer.init_line(item)
1089
- @surface.commit(line) if line
1090
- else @surface.commit(EventRenderer.hook_notice_line(item))
1091
- end
1012
+ state = @engine.session_state_snapshot.merge(model_name: @effective_model_name)
1013
+ @status_row.take_state(state, default_model: @default_model_name)
1092
1014
  end
1093
1015
 
1094
1016
  # Resolve the recap config (on by default). Returns false when explicitly
@@ -1131,7 +1053,7 @@ module Samagotchi
1131
1053
  type = event[:type] || event["type"]
1132
1054
  return unless type.to_s == "question_requested"
1133
1055
 
1134
- pq = event[:pending_question] || event["pending_question"] || event[:pendingQuestion]
1056
+ pq = event[:pending_question] || event["pending_question"]
1135
1057
  return unless pq
1136
1058
 
1137
1059
  @pending_question_event = pq
@@ -50,10 +50,10 @@ module Samagotchi
50
50
  # The level for +target+ and where it came from ("--thinking",
51
51
  # "models: qwen", "hosts.work", "thinking.level"; nil for the default).
52
52
  # @param target [HostRegistry::ModelTarget]
53
- # @param names [Array<String>, nil] the model's lookup names (Engine#model_lookup_names)
53
+ # @param names [Array<String>] the model's lookup names (HostRegistry#lookup_names)
54
54
  # @param models [Hash, nil] ConfigFile.model_settings (specs)
55
55
  # @return [Array(Symbol, String|nil)]
56
- def resolve(target, names: nil, models: nil)
56
+ def resolve(target, names:, models: nil)
57
57
  global, origin = global_level
58
58
  return [global, origin == :cli ? "--thinking" : "SAMAGOTCHI_THINKING_LEVEL"] if global && %i[cli env].include?(origin)
59
59
 
@@ -62,7 +62,6 @@ module Samagotchi
62
62
  rescue StandardError
63
63
  {}
64
64
  end
65
- names ||= [target.model, target.bare_model]
66
65
  key, value = ConfigFile.model_setting(names, :thinking, models: models)
67
66
  return [value, "models: #{key}"] if key
68
67
 
@@ -18,21 +18,22 @@ module Samagotchi
18
18
  # fragments at the end of each chunk so a marker split across a stream boundary
19
19
  # is still recognized. `ThoughtStreamSplitter.for_profile` derives the block
20
20
  # markers from the active ModelProfile, so the same splitter works for the
21
- # Qwen literal-marker family and (tool_call only) the Gemma control-token family.
21
+ # Qwen literal-marker family and the Gemma control-token family.
22
22
  #
23
- # For a profile whose `thought_close` is nil (Gemma 4), thinking blocks have no
24
- # explicit close marker and are NOT split out — they remain in `:text` and the
25
- # renderer's existing strip still hides them from the bubble. Qwen 3.6 (the
26
- # default runtime profile) has explicit open/close markers for both families,
27
- # so thinking is split cleanly.
23
+ # Gemma 4 thinks in a channel, `<|channel>thought` … `<channel|>`: that is
24
+ # its thinking block. Its bare `<|think|>` cue has no close marker, so it is
25
+ # not a block and stays in `:text` (the model doesn't emit it; the system
26
+ # prompt carries it).
28
27
  class ThoughtStreamSplitter
28
+ # Gemma 4's thought channel (ToolCallParser::Gemma strips the same pair).
29
+ GEMMA_THOUGHT_CHANNEL = { open: "<|channel>thought", close: "<channel|>" }.freeze
30
+
29
31
  # @return [Array<Hash>] each entry { open: String, close: String, lane: :thinking|:drop }
30
32
  def self.for_profile(profile)
31
33
  blocks = []
32
- # Only register a thinking block when the profile has an explicit close
33
- # marker (Qwen). A nil close (Gemma) means we can't bound the block, so we
34
- # leave thinking in :text and let the renderer's strip handle it.
34
+ # A thinking block needs a close marker to be bounded.
35
35
  blocks << { open: profile.thought_open, close: profile.thought_close, lane: :thinking } if profile.thought_close
36
+ blocks << GEMMA_THOUGHT_CHANNEL.merge(lane: :thinking) if profile.name == "gemma4"
36
37
  blocks << { open: profile.tool_call_open, close: profile.tool_call_close, lane: :drop }
37
38
  new(blocks)
38
39
  end