samagotchi 0.5.0 → 0.6.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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/bin/chi +32 -891
  4. data/docs/architecture.md +8 -1
  5. data/docs/cli.md +12 -13
  6. data/docs/configuration.md +4 -1
  7. data/docs/desktop.md +3 -1
  8. data/docs/hooks.md +69 -4
  9. data/docs/plugins.md +98 -3
  10. data/docs/sessions.md +3 -3
  11. data/lib/samagotchi/bridge/card_store.rb +30 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +7 -4
  13. data/lib/samagotchi/bridge/turn_notice.rb +37 -0
  14. data/lib/samagotchi/bundle_command.rb +652 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +4 -4
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +214 -2
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +3 -0
  18. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  19. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  20. data/lib/samagotchi/cancellation_controller.rb +42 -1
  21. data/lib/samagotchi/config.rb +2 -2
  22. data/lib/samagotchi/desktop/macos/ChiRunner.swift +20 -5
  23. data/lib/samagotchi/desktop/macos/Panel.swift +31 -3
  24. data/lib/samagotchi/engine.rb +138 -618
  25. data/lib/samagotchi/guardrail_wiring.rb +238 -0
  26. data/lib/samagotchi/hooks/registry.rb +11 -3
  27. data/lib/samagotchi/hooks/stream_watch.rb +93 -0
  28. data/lib/samagotchi/hooks.rb +3 -0
  29. data/lib/samagotchi/kernel_loop.rb +65 -6
  30. data/lib/samagotchi/llm/chat_loop.rb +73 -11
  31. data/lib/samagotchi/llm/openai_chat.rb +16 -4
  32. data/lib/samagotchi/log_subscriber.rb +10 -4
  33. data/lib/samagotchi/plugin/api.rb +8 -0
  34. data/lib/samagotchi/plugin/context.rb +12 -3
  35. data/lib/samagotchi/plugin_tasks.rb +229 -0
  36. data/lib/samagotchi/question_desk.rb +280 -0
  37. data/lib/samagotchi/recap_setup.rb +128 -0
  38. data/lib/samagotchi/reply_wait.rb +9 -0
  39. data/lib/samagotchi/send_command.rb +3 -6
  40. data/lib/samagotchi/session.rb +9 -1
  41. data/lib/samagotchi/sessions_command.rb +255 -0
  42. data/lib/samagotchi/steer.rb +19 -2
  43. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -5
  44. data/lib/samagotchi/terminal_ui/formatting.rb +4 -2
  45. data/lib/samagotchi/tool_declarations.rb +2 -2
  46. data/lib/samagotchi/tools/delegate.rb +6 -2
  47. data/lib/samagotchi/tools/delegate_wait.rb +26 -8
  48. data/lib/samagotchi/turn_note.rb +20 -1
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +28 -10
  51. data/lib/samagotchi/web/public/app.js +94 -52
  52. data/lib/samagotchi/web/public/card.js +8 -1
  53. data/lib/samagotchi/web/public/format.js +1 -1
  54. data/lib/samagotchi/web/public/index.html +2 -5
  55. data/lib/samagotchi/web/public/stage_model.js +44 -0
  56. data/lib/samagotchi/web/public/stage_view.js +26 -17
  57. data/lib/samagotchi/web/public/timing.js +9 -3
  58. data/lib/samagotchi/web/public/turn_events.js +18 -5
  59. data/lib/samagotchi/web/public/turn_view.js +80 -16
  60. data/lib/samagotchi/web/server.rb +4 -3
  61. metadata +9 -2
  62. data/lib/samagotchi/web/public/chat_view.js +0 -376
@@ -33,6 +33,7 @@ require_relative "system_prompt"
33
33
  require_relative "session_metrics"
34
34
  require_relative "token_usage"
35
35
  require_relative "idle_recap"
36
+ require_relative "recap_setup"
36
37
  require_relative "idle_reminders"
37
38
  require_relative "idle_scheduler"
38
39
  require_relative "hooks"
@@ -50,6 +51,9 @@ require_relative "sampling_settings"
50
51
  require_relative "thinking"
51
52
  require_relative "answer_display"
52
53
  require_relative "edit_preview"
54
+ require_relative "question_desk"
55
+ require_relative "guardrail_wiring"
56
+ require_relative "plugin_tasks"
53
57
 
54
58
  module Samagotchi
55
59
  # Engine owns the core agent logic: system prompt construction, tool
@@ -61,7 +65,7 @@ module Samagotchi
61
65
  # Raised by #answer_question when the question it targets is no longer
62
66
  # open (never asked, superseded, already answered or cancelled). A subclass
63
67
  # of ArgumentError for existing callers; transports map it to 409 Conflict.
64
- class QuestionNotPending < ArgumentError; end
68
+ QuestionNotPending = QuestionDesk::NotPending
65
69
 
66
70
  # Build a system prompt string for the given profile.
67
71
  # Used by specs and inspection.
@@ -118,11 +122,31 @@ module Samagotchi
118
122
  # Load hooks from config (plugins) and create the registry; what fails
119
123
  # to load is announced, and a required guardrail's failure denies
120
124
  # every tool call. Rules load now too, so their errors are announced.
121
- @guardrail_failures = Guardrails::LoadFailures.new
122
- @guardrail_rules_mutex = Mutex.new
125
+ @guardrail_wiring = GuardrailWiring.new(
126
+ scratch: @scratch,
127
+ hooks: -> { @hooks },
128
+ tools: -> { @tools },
129
+ session: -> { @session },
130
+ model_key: -> { @model_key },
131
+ cancelled: -> { !!active_cancel_controller&.cancelled? },
132
+ ask: ->(fields) { open_question(fields) }
133
+ )
134
+ @guardrail_failures = @guardrail_wiring.failures
123
135
  # Plugins that failed to load: announced apart, as plugins (not
124
136
  # guardrails: no tool call is denied for them).
125
137
  @plugin_failures = Guardrails::LoadFailures.new
138
+ # The question flow (ask_user_question, hooks' and plugins' ask_user,
139
+ # approvals). Built before the hooks and plugins load: a plugin can ask
140
+ # during its load (answered nil then: the interface is still
141
+ # :non_interactive).
142
+ @question_desk = QuestionDesk.new(
143
+ session: -> { @session },
144
+ state_dir: -> { session_state_dir },
145
+ emit: ->(event) { emit_event(nil, event) },
146
+ cancel_controller: -> { active_cancel_controller },
147
+ interface: -> { interface },
148
+ user_input: ->(sid) { ArchiveStore.user_input(sid, state_dir: session_state_dir) }
149
+ )
126
150
  @hooks = load_hooks_from_config
127
151
  load_hooks_from_bundles
128
152
  # The tools this session offers (the prompts' declarations and the
@@ -143,11 +167,19 @@ module Samagotchi
143
167
  # commands running now (#spawn_anytime), which it waits for.
144
168
  @services = Plugin::Services.new
145
169
  @anytime_threads = []
146
- # Plugins' tool sets from chi.replace_tools, by bundle, until the
147
- # turn thread applies them (#apply_staged_tools!).
148
- @staged_tools = {}
149
- # chi.init tasks (#add_init_task), started by #start_init_tasks!.
150
- @init_tasks = []
170
+ # chi.init tasks (#add_init_task), started by #start_init_tasks!, and
171
+ # plugins' tool sets from chi.replace_tools until the turn thread
172
+ # applies them (#apply_staged_tools!). Built before the plugins load.
173
+ @plugin_tasks = PluginTasks.new(
174
+ clock: -> { monotonic_now },
175
+ synchronize_events: ->(&block) { synchronize_events(&block) },
176
+ announce: ->(event) { announce(event) },
177
+ emit: ->(sink, event) { emit_event(sink, event) },
178
+ show_card: ->(**card) { show_card(**card) },
179
+ tools: -> { @tools },
180
+ notify: ->(text, level, source) { hook_notify(text, level, source) },
181
+ tools_changed: -> { tools_changed! }
182
+ )
151
183
  @lifecycle_mutex = Mutex.new
152
184
  @shut_down = false
153
185
  load_plugins if plugins
@@ -190,30 +222,13 @@ module Samagotchi
190
222
  @kernel.question_handler = proc { |payload| request_question(payload) } if @kernel.respond_to?(:question_handler=)
191
223
  # Every tool call asks this gate first. The kernel is never rebuilt, so
192
224
  # it holds across model switches.
193
- @guardrail_git = Guardrails::GitInfo.new
194
225
  self.guardrail_state_dir = Session.default_state_dir
195
226
  # list_sessions and send_note speak for whichever session runs now.
196
227
  @kernel.peers = PeerView.new(self) if @kernel.respond_to?(:peers=)
197
- if @kernel.respond_to?(:guardrail_gate=)
198
- @kernel.guardrail_gate = Guardrails::Gate.new(
199
- -> { @hooks },
200
- context_lookup: -> { guardrail_context },
201
- model_key_lookup: -> { @model_key },
202
- approver: ->(verdict) { request_approval(verdict) },
203
- approvals_lookup: -> { @guardrail_approvals },
204
- checks_lookup: -> { guardrail_checks },
205
- cancelled_lookup: -> { !!active_cancel_controller&.cancelled? },
206
- tools_lookup: -> { @tools }
207
- )
208
- end
228
+ @kernel.guardrail_gate = @guardrail_wiring.gate if @kernel.respond_to?(:guardrail_gate=)
209
229
  # What a hook can do beyond reading its event (event[:notify],
210
230
  # event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
211
231
  @hooks.runtime = hook_runtime
212
- # If session was resumed and has a pending_question, hydrate engine state
213
- if @resume_session && @resume_session.pending_question
214
- @pending_question = @resume_session.pending_question.dup
215
- @session = @resume_session
216
- end
217
232
  # The loop follows the effective model's host (its api:): the raw-prompt
218
233
  # NativeBackend, or the chat backend for openai hosts.
219
234
  @native_backend = LLM::NativeBackend.new(kernel: @kernel)
@@ -248,11 +263,9 @@ module Samagotchi
248
263
  # Reminder names the interactive REPL's IdleReminders callback marked due;
249
264
  # the REPL polls them to decide when to run a synthetic reminder turn.
250
265
  @due_reminder_names = []
251
- @question_mutex = Monitor.new
252
- @question_cv = @question_mutex.new_cond
253
- @pending_question = nil
254
- @question_answer = nil
255
- @recap = build_recap(recap)
266
+ @recap = RecapSetup.build(recap, engine: self, host_registry: @host_registry,
267
+ session_target: -> { session_model_recap_target },
268
+ session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
256
269
  # One shared poller for the whole idle layer (reminders + optional
257
270
  # recap). Both jobs read the activity seam above; the scheduler owns
258
271
  # the single background thread and isolates per-job failures.
@@ -587,135 +600,41 @@ module Samagotchi
587
600
 
588
601
  # ── Plugin init tasks (chi.init) ──────────────────────────────────────
589
602
 
590
- # A plugin's slow setup (docs/plugins.md, Init tasks): run on its own
591
- # thread once the owner can show it (#start_init_tasks!), announced as
592
- # plugin_init_started / plugin_init_finished unless quiet. A turn waits
593
- # for the running ones that provide tools before its first model request
594
- # (#await_init_tasks), up to each one's timeout. +cancel+ is its own
595
- # controller, cancelled only by #shutdown: a Ctrl-C ends a turn's wait,
596
- # not the task.
597
- InitTask = Struct.new(:id, :bundle, :label, :plugin_label, :provides_tools, :quiet, :timeout, :failed, :block,
598
- :cancel, :thread, :state, :started_at, keyword_init: true) do
599
- # What the task's block reads: whether chi is shutting down.
600
- def cancelled? = cancel.cancelled?
601
- end
602
-
603
- # How long a task may hold a turn when it gives no timeout.
604
- INIT_TASK_TIMEOUT = 60.0
603
+ # A plugin's slow setup (docs/plugins.md, Init tasks): PluginTasks.
604
+ InitTask = PluginTasks::InitTask
605
+ INIT_TASK_TIMEOUT = PluginTasks::INIT_TASK_TIMEOUT
606
+ INIT_WAIT_POLL = PluginTasks::INIT_WAIT_POLL
605
607
 
606
608
  # Add a plugin's init task (Plugin::Api#init at commit); it starts with
607
609
  # #start_init_tasks!.
608
610
  def add_init_task(bundle:, label:, plugin_label:, provides_tools:, quiet:, timeout:, failed: nil, &block)
609
- @lifecycle_mutex.synchronize do
610
- @init_tasks << InitTask.new(id: "#{bundle}-#{@init_tasks.size + 1}", bundle: bundle.to_s, label: label.to_s,
611
- plugin_label: plugin_label, provides_tools: provides_tools ? true : false,
612
- quiet: quiet ? true : false, timeout: timeout || INIT_TASK_TIMEOUT, failed: failed,
613
- block: block, cancel: CancellationController.new, state: :pending)
614
- end
615
- nil
611
+ @plugin_tasks.add(bundle: bundle, label: label, plugin_label: plugin_label, provides_tools: provides_tools,
612
+ quiet: quiet, timeout: timeout, failed: failed, &block)
616
613
  end
617
614
 
618
- # Start the init tasks not started yet, each on its own thread. The
619
- # worker calls it once its Bridge is up, the REPL once it renders
620
- # events, and every turn (a -p run has only that); later calls start
621
- # nothing new.
615
+ # Start the init tasks not started yet, each on its own thread
616
+ # (PluginTasks#start!).
622
617
  def start_init_tasks!
623
- @lifecycle_mutex.synchronize do
624
- return if @shut_down
625
-
626
- @init_tasks.each do |task|
627
- next unless task.state == :pending
628
-
629
- task.state = :starting
630
- task.started_at = monotonic_now
631
- task.thread = Thread.new { run_init_task(task) }
632
- task.thread.report_on_exception = false
633
- end
634
- end
635
- nil
618
+ @plugin_tasks.start!
636
619
  end
637
620
 
638
621
  # The running init tasks a UI shows (not the quiet ones), for a UI that
639
622
  # joins while they run (Bridge#snapshot, with the event log held).
640
623
  # @return [Array<Hash>] {bundle:, id:, label:}
641
624
  def init_tasks
642
- @lifecycle_mutex.synchronize do
643
- @init_tasks.select { |task| task.state == :running && !task.quiet }
644
- .map { |task| { bundle: task.bundle, id: task.id, label: task.label } }
645
- end
625
+ @plugin_tasks.running
646
626
  end
647
627
 
648
- # Wait for the running init tasks that provide tools, each up to its
649
- # timeout from its start, while +controller+ isn't cancelled; tell the
650
- # turn's sink what it waits for (:plugin_init_wait). A task that ends
651
- # late or fails leaves the turn without its tools.
628
+ # Wait for the running init tasks that provide tools (PluginTasks#await).
652
629
  # @return [Boolean] whether it waited
653
630
  def await_init_tasks(controller = nil, on_event = nil)
654
- waiting = @lifecycle_mutex.synchronize do
655
- @init_tasks.select { |task| task.provides_tools && %i[starting running].include?(task.state) }
656
- end
657
- return false if waiting.empty?
658
-
659
- emit_event(on_event, { type: :plugin_init_wait,
660
- tasks: waiting.map { |task| { bundle: task.bundle, id: task.id, label: task.label } } })
661
- started = monotonic_now
662
- loop do
663
- now = monotonic_now
664
- left = waiting.select { |task| %i[starting running].include?(task.state) && now < task.started_at + task.timeout }
665
- break if left.empty? || controller&.cancelled?
666
-
667
- sleep(INIT_WAIT_POLL)
668
- end
669
- Log.info(:plugins, "init_wait", ms: ((monotonic_now - started) * 1000).round,
670
- cancelled: controller&.cancelled? ? true : nil)
671
- true
631
+ @plugin_tasks.await(controller, on_event)
672
632
  end
673
633
 
674
- INIT_WAIT_POLL = 0.05
675
-
676
634
  # The init task this thread runs, or nil.
677
- def current_init_task = Thread.current[:"samagotchi_init_#{object_id}"]
635
+ def current_init_task = @plugin_tasks.current
678
636
  private :current_init_task
679
637
 
680
- def run_init_task(task)
681
- Thread.current[:"samagotchi_init_#{object_id}"] = task
682
- synchronize_events do
683
- task.state = :running
684
- announce({ type: :plugin_init_started, bundle: task.bundle, id: task.id, label: task.label }) unless task.quiet
685
- end
686
- Log.info(:plugins, "init_started", bundle: task.bundle, id: task.id, label: task.label)
687
- summary = task.block.call(task)
688
- finish_init_task(task, ok: true, summary: summary.is_a?(String) ? summary : nil)
689
- rescue StandardError => e
690
- finish_init_task(task, ok: false, error: e.message)
691
- end
692
- private :run_init_task
693
-
694
- def finish_init_task(task, ok:, summary: nil, error: nil)
695
- Log.public_send(ok ? :info : :warn, :plugins, "init_finished", bundle: task.bundle, id: task.id, ok: ok,
696
- ms: ((monotonic_now - task.started_at) * 1000).round,
697
- msg: error)
698
- shut_down = @lifecycle_mutex.synchronize { @shut_down }
699
- synchronize_events do
700
- task.state = ok ? :done : :failed
701
- next if shut_down || (task.quiet && ok)
702
-
703
- unless task.quiet
704
- announce({ type: :plugin_init_finished, bundle: task.bundle, id: task.id, label: task.label, ok: ok,
705
- summary: summary, error: error }.compact)
706
- end
707
- # A failure stays on screen (and for a UI that joins later) as a
708
- # card: a short title (the card shows the bundle beside it), the
709
- # detail in the body.
710
- unless ok
711
- title = task.failed || "setup failed"
712
- body = task.failed ? error.to_s : "#{task.label}: #{error}"
713
- show_card(source: task.bundle, title: title, body: body, level: :warn, id: "init-#{task.id}")
714
- end
715
- end
716
- end
717
- private :finish_init_task
718
-
719
638
  # ── Load events ───────────────────────────────────────────────────────
720
639
 
721
640
  # Announce what failed to load and what plugins showed while loading,
@@ -755,8 +674,7 @@ module Samagotchi
755
674
 
756
675
  @shut_down = true
757
676
  # An init task's requests end (a server's boot), so it finishes.
758
- @init_tasks.each { |task| task.cancel.cancel!(:shutdown) }
759
- @anytime_threads.dup + @init_tasks.filter_map(&:thread)
677
+ @anytime_threads.dup + @plugin_tasks.shut_down!
760
678
  end
761
679
  # #stop_idle's work (the scheduler's stop is idempotent), where the
762
680
  # callers haven't stopped it already.
@@ -769,7 +687,7 @@ module Samagotchi
769
687
  end
770
688
  left = threads.count(&:alive?)
771
689
  Log.warn(:plugins, "anytime_commands_left", count: left) if left.positive?
772
- @init_tasks.each { |task| task.thread&.kill if task.thread&.alive? }
690
+ @plugin_tasks.kill_leftovers
773
691
  @services.stop_all
774
692
  self
775
693
  end
@@ -1035,7 +953,7 @@ module Samagotchi
1035
953
  last_prompt: @session&.last_prompt,
1036
954
  event_seq: @session_observer&.event_count,
1037
955
  metrics: metrics,
1038
- pending_question: @question_mutex.synchronize { @pending_question&.dup },
956
+ pending_question: @question_desk.pending,
1039
957
  used_memory_names: @used_memory_mutex.synchronize { @used_memory_names.dup },
1040
958
  preloaded_memory_names: preloaded_memory_names,
1041
959
  muted_memory_names: @muted_memory_names.dup,
@@ -1144,14 +1062,11 @@ module Samagotchi
1144
1062
  # default, so a bare Engine denies instead of waiting for nobody). Set
1145
1063
  # by the host (TerminalUI, Worker).
1146
1064
  def interface
1147
- @interface || :non_interactive
1065
+ @guardrail_wiring.interface
1148
1066
  end
1149
1067
 
1150
1068
  def interface=(value)
1151
- value = value.to_sym
1152
- raise ArgumentError, "unknown interface #{value}" unless Guardrails::Context::INTERFACES.include?(value)
1153
-
1154
- @interface = value
1069
+ @guardrail_wiring.interface = value
1155
1070
  end
1156
1071
 
1157
1072
  # Where the approval store lives: beside Session's state dir
@@ -1178,128 +1093,36 @@ module Samagotchi
1178
1093
  def guardrail_state_dir=(state_dir)
1179
1094
  # Also where list_sessions and send_note look for other sessions.
1180
1095
  @state_dir = state_dir
1181
- @guardrail_approvals = Guardrails::Approvals.new(dir: Guardrails::Approvals.dir_for(state_dir))
1182
- @guardrail_protected = nil
1096
+ @guardrail_wiring.state_dir = state_dir
1183
1097
  end
1184
1098
 
1185
- # The gate's core checks, in order.
1099
+ # The gate's core checks, in order (GuardrailWiring#checks).
1186
1100
  def guardrail_checks
1187
- rules = guardrail_rules
1188
- [@guardrail_failures, rules.hook_asks, guardrail_protected_paths, (@scratch_writes ||= Guardrails::ScratchWrites.new if @scratch),
1189
- rules].compact
1101
+ @guardrail_wiring.checks
1190
1102
  end
1191
1103
 
1192
- # The YAML rules: config.yml's `guardrails:` section (rules, disable) and
1193
- # installed bundles'. One that doesn't parse is a required load failure
1194
- # (every call is denied). Read again when one of those files changed
1195
- # (a stat of each per tool call), so a long-lived worker follows edits.
1104
+ # The YAML rules, read again when a file changed (GuardrailWiring#rules).
1196
1105
  # @return [Guardrails::Rules]
1197
1106
  def guardrail_rules
1198
- @guardrail_rules_mutex.synchronize do
1199
- stamp = guardrail_rules_stamp
1200
- if @guardrail_rules.nil? || stamp != @guardrail_rules_stamp
1201
- @guardrail_failures.drop(:rules)
1202
- @guardrail_rules = load_guardrail_rules
1203
- @guardrail_rules_stamp = stamp
1204
- end
1205
- @guardrail_rules
1206
- end
1207
- end
1208
-
1209
- # [path, mtime, size] of config.yml and every installed bundle's
1210
- # manifest.json and guardrails/ file.
1211
- def guardrail_rules_stamp
1212
- require_relative "memory_bundle/provenance"
1213
- paths = [Samagotchi::ConfigFile.global_path] +
1214
- Dir[File.join(MemoryBundle::Provenance.bundles_dir, "*", "{manifest.json,guardrails/*}")]
1215
- paths.compact.sort.map do |path|
1216
- stat = File.stat(path)
1217
- [path, stat.mtime.to_r, stat.size]
1218
- rescue SystemCallError
1219
- [path]
1220
- end
1221
- end
1222
-
1223
- def load_guardrail_rules
1224
- section = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
1225
- section = section["guardrails"] if section.is_a?(Hash)
1226
- rules = []
1227
- disable = []
1228
- begin
1229
- raise Guardrails::Rules::ParseError, "guardrails must be a mapping" unless section.nil? || section.is_a?(Hash)
1230
-
1231
- rules = Guardrails::Rules.parse(section && section["rules"], source: "config")
1232
- disable = Guardrails::Rules.parse_disable(section && section["disable"])
1233
- rescue Guardrails::Rules::ParseError => e
1234
- Log.warn(:guardrails, "config_rules_invalid", echo: "[samagotchi:guardrails] config.yml guardrails rules: #{e.message}")
1235
- @guardrail_failures.add("rules in config.yml", e.message, required: true, group: :rules)
1236
- end
1237
- Guardrails::Rules.new(rules + bundle_guardrail_rules, disable: disable,
1238
- enabled: Samagotchi::Config.get("guardrails.enabled") != false)
1107
+ @guardrail_wiring.rules
1239
1108
  end
1240
- private :guardrail_rules_stamp, :load_guardrail_rules
1241
1109
 
1242
- # Installed bundles' guardrails/*.yml, by bundle name then file name.
1243
- # A file that is missing, changed since install (sha256) or doesn't
1244
- # parse is a required load failure.
1110
+ # Installed bundles' guardrails/*.yml (GuardrailWiring#bundle_rules).
1245
1111
  def bundle_guardrail_rules
1246
- require_relative "memory_bundle/provenance"
1247
- rules = []
1248
- MemoryBundle::Provenance.each_installed_with_guardrails do |bundle_name, data|
1249
- if data[:error]
1250
- Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{data[:error]}", bundle: bundle_name)
1251
- @guardrail_failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
1252
- next
1253
- end
1254
- dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
1255
- data[:guardrails].sort_by { |k, _| k.to_s }.each do |basename, meta|
1256
- what = "rules #{basename} (bundle #{bundle_name})"
1257
- path = File.join(dir, basename.to_s)
1258
- begin
1259
- raise Guardrails::Rules::ParseError, "the file is missing" unless File.file?(path)
1260
-
1261
- expected = (meta.is_a?(Hash) ? meta[:sha256] : nil).to_s.sub(/\Asha256:/, "")
1262
- actual = Digest::SHA256.hexdigest(File.binread(path))
1263
- if expected != actual
1264
- raise Guardrails::Rules::ParseError, "its sha256 differs from the installed one (edited after install? reinstall the bundle)"
1265
- end
1266
-
1267
- doc = YAML.safe_load(File.read(path))
1268
- raise Guardrails::Rules::ParseError, "expected a mapping with rules:" unless doc.is_a?(Hash)
1269
-
1270
- rules.concat(Guardrails::Rules.parse(doc["rules"], source: "bundle #{bundle_name}"))
1271
- rescue Guardrails::Rules::ParseError, Psych::Exception => e
1272
- Log.warn(:guardrails, "rules_file_invalid", echo: "[samagotchi:guardrails] #{what}: #{e.message}", bundle: bundle_name, file: basename.to_s)
1273
- @guardrail_failures.add(what, e.message, required: true, group: :rules)
1274
- end
1275
- end
1276
- end
1277
- rules
1278
- rescue StandardError => e
1279
- Log.error(:guardrails, "bundle_rules_failed", echo: "[samagotchi:guardrails] failed to read installed bundles' rules: #{e.class}: #{e.message}", error: e.class.name)
1280
- @guardrail_failures.add("bundle rules", "#{e.class}: #{e.message}", required: true, group: :rules)
1281
- rules || []
1112
+ @guardrail_wiring.bundle_rules
1282
1113
  end
1283
1114
 
1284
1115
  # @return [Guardrails::LoadFailures]
1285
1116
  attr_reader :guardrail_failures
1286
1117
 
1287
1118
  def guardrail_protected_paths
1288
- @guardrail_protected ||= begin
1289
- require_relative "memory_bundle/provenance"
1290
- config = Samagotchi::ConfigFile.read_yaml(path: Samagotchi::ConfigFile.global_path)
1291
- hooks_dir = config.is_a?(Hash) && config["hooks"].is_a?(Hash) ? config["hooks"]["hooks_dir"] : nil
1292
- Guardrails::ProtectedPaths.new(
1293
- store_dir: File.dirname(@guardrail_approvals.path),
1294
- bundles_dir: MemoryBundle::Provenance.bundles_dir,
1295
- config_path: Samagotchi::ConfigFile.global_path,
1296
- hooks_dir: Hooks::Loader.expand_path(hooks_dir || Hooks::Loader.default_hooks_dir)
1297
- )
1298
- end
1119
+ @guardrail_wiring.protected_paths
1299
1120
  end
1300
1121
 
1301
1122
  # @return [Guardrails::Approvals]
1302
- attr_reader :guardrail_approvals
1123
+ def guardrail_approvals
1124
+ @guardrail_wiring.approvals
1125
+ end
1303
1126
 
1304
1127
  # @return [Guardrails::LoadFailures] the plugins that failed to load
1305
1128
  attr_reader :plugin_failures
@@ -1334,37 +1157,16 @@ module Samagotchi
1334
1157
  # The context the gate sees for a tool call now.
1335
1158
  # @return [Guardrails::Context]
1336
1159
  def guardrail_context
1337
- Guardrails::Context.new(cwd: Dir.pwd, session_id: @session&.id, interface: interface,
1338
- origin: @turn_origin, git: @guardrail_git)
1160
+ @guardrail_wiring.context
1339
1161
  end
1340
1162
 
1341
- # Ask the user to approve a call the gate voted `ask` on, through the
1342
- # question flow (REPL sync handler, attached TUI, web). Settles the
1343
- # verdict: allow with the picked scope, or deny with a note for the
1344
- # model. A --non-interactive run has no one to ask and denies at once.
1163
+ # Ask the user to approve a call the gate voted `ask` on
1164
+ # (GuardrailWiring#request_approval).
1345
1165
  # @param verdict [Guardrails::Verdict]
1346
1166
  # @return [Guardrails::Verdict]
1347
1167
  def request_approval(verdict)
1348
- if interface == :non_interactive
1349
- return verdict.settle!(:deny, decided_by: "no one", note: "No one to approve it (non-interactive run).")
1350
- end
1351
-
1352
- # A plugin tool is asked about by its label, as its row shows it.
1353
- label = ToolActivity.plugin_label(verdict.call[:name].to_s, registry: @tools)
1354
- # verdict.call is the call that will run (a hook may have replaced it).
1355
- payload = Guardrails::Approval.payload(verdict, label: label, preview: approval_preview(verdict.call))
1356
- Guardrails::Approval.settle(verdict, open_question(payload), payload[:approval][:scopes])
1357
- end
1358
-
1359
- # The dry-run diff of an edit/write call for its approval; a preview
1360
- # that fails only leaves the diff out, it never denies the call.
1361
- def approval_preview(call)
1362
- EditPreview.for(call)
1363
- rescue StandardError => e
1364
- Log.warn(:guardrails, "edit_preview_failed", error: "#{e.class}: #{e.message}")
1365
- nil
1168
+ @guardrail_wiring.request_approval(verdict)
1366
1169
  end
1367
- private :approval_preview
1368
1170
 
1369
1171
  # The conversation as a hook may read it: a frozen array of copied
1370
1172
  # messages, so a hook cannot change what the turn sends or stores.
@@ -1416,7 +1218,8 @@ module Samagotchi
1416
1218
  notify: ->(text:, level:, hook:) { hook_notify(text, level, hook) },
1417
1219
  ask_user: ->(question:, options:, header:, allow_freeform:, hook:) { hook_ask_user(question, options, header, allow_freeform, hook) },
1418
1220
  stop_turn: ->(reason:, hook:) { hook_stop_turn(reason, hook) },
1419
- steer: ->(text:, hook:) { steer(text, source: hook_source(hook)) }
1221
+ steer: ->(text:, hook:) { steer(text, source: hook_source(hook)) },
1222
+ stop_generation: ->(reason:, hook:) { hook_stop_generation(reason, hook) }
1420
1223
  )
1421
1224
  end
1422
1225
  private :hook_runtime
@@ -1462,22 +1265,7 @@ module Samagotchi
1462
1265
  # answer, cancelled) is nil too.
1463
1266
  # @return [Hash, nil] {selected:, freeform:, selected_indices:}
1464
1267
  def hook_ask_user(question, options, header, allow_freeform, hook)
1465
- return nil if interface == :non_interactive
1466
-
1467
- opts = Tools::AskUserQuestion.normalize_options(options)
1468
- unless opts
1469
- Log.warn(:hooks, "ask_user_invalid", echo: "[samagotchi:hooks] #{hook} asked with invalid options (2-8 strings)", hook: hook.to_s)
1470
- return nil
1471
- end
1472
-
1473
- fields = { question: question.to_s, options: opts, header: header, multi_select: false,
1474
- allow_freeform: !!allow_freeform, kind: "hook", hook: hook.to_s }.compact
1475
- answer = open_question(fields)
1476
- return nil unless answer.is_a?(Hash) && answer[:selected]
1477
-
1478
- result = { selected: Array(answer[:selected]), freeform: answer[:freeform] }
1479
- result[:selected_indices] = answer[:selected_indices] if answer.key?(:selected_indices)
1480
- result
1268
+ @question_desk.ask_for_hook(question, options, header, allow_freeform, hook)
1481
1269
  end
1482
1270
  private :hook_ask_user
1483
1271
 
@@ -1494,216 +1282,58 @@ module Samagotchi
1494
1282
  end
1495
1283
  private :hook_stop_turn
1496
1284
 
1285
+ # Cut the streaming generation (reason :hook); the turn goes on and asks
1286
+ # the model again (the loops' cut path). No notice: the plugin posts its
1287
+ # own. The bundle and +reason+ go to the log and the retry's nudge.
1288
+ # @return [Boolean] true when a streaming generation was cut now
1289
+ def hook_stop_generation(reason, hook)
1290
+ ctrl = active_cancel_controller
1291
+ return false unless ctrl
1292
+
1293
+ ctrl.cancel_generation!(:hook, { by: hook_source(hook), reason: reason.to_s })
1294
+ end
1295
+ private :hook_stop_generation
1296
+
1497
1297
  # ── Ask-user-question (structured qualification) ──────────────────────────
1498
1298
 
1499
1299
  # @return [Hash, nil] current pending question (thread-safe copy)
1500
1300
  def pending_question
1501
- @question_mutex.synchronize { @pending_question&.dup }
1301
+ @question_desk.pending
1502
1302
  end
1503
1303
 
1504
1304
  # Ask the model's question (ask_user_question): the kernel's
1505
1305
  # question_handler, called on the turn thread with the payload
1506
- # Tools::AskUserQuestion.validate made. Opens it (#open_question) and
1507
- # returns the answer as JSON for the tool result.
1306
+ # Tools::AskUserQuestion.validate made (QuestionDesk#request).
1508
1307
  # @param payload [Hash] {question:, options:, header:, multi_select:, allow_freeform:}
1509
1308
  # @return [String] normalized answer JSON
1510
1309
  def request_question(payload)
1511
- result = open_question(**payload.slice(:question, :options, :header),
1512
- multi_select: !!payload[:multi_select], allow_freeform: !!payload[:allow_freeform])
1513
- # Dismissed (the card's dismiss, Esc): an answer of its own, not a
1514
- # tool failure the model learns to avoid the tool from.
1515
- result = { dismissed: true, id: result[:id], note: QUESTION_DISMISSED_NOTE } if result.is_a?(Hash) && result[:error] == "no answer"
1516
- result.is_a?(String) ? result : JSON.generate(result)
1517
- end
1518
-
1519
- QUESTION_DISMISSED_NOTE = "The user dismissed the question without answering. Go on with your best judgement, " \
1520
- "or ask in your reply if you can't."
1521
-
1522
- # Open a question for the UIs and wait for its answer. Emits
1523
- # :question_requested, persists it to the session, and BLOCKS until
1524
- # answer_question / cancel_question wakes it (or the turn is cancelled).
1525
- # The fields go to pending_question as given (no cleaning), extra keys
1526
- # included, so a caller can add its own (kind:, approval:).
1527
- # @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
1528
- # @return [Hash, String] the answer {id:, selected:, freeform:, selected_indices:},
1529
- # or {error:, …}; a String when a sync handler returned text itself
1530
- def open_question(fields)
1531
- id = SecureRandom.uuid
1532
- pending = { id: id, **fields, status: "pending", created_at: Time.now.iso8601(3) }.compact
1533
-
1534
- @question_mutex.synchronize do
1535
- @pending_question = pending
1536
- @question_answer = nil
1537
- end
1538
- # Persist to session file for WEB stub + resume (generic for all UIs)
1539
- if @session
1540
- @session.pending_question = pending.dup
1541
- begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1542
- end
1543
- # Generic emit for all UIs (TUI, WEB, Bridge, future). Observers that
1544
- # stash this event (e.g. TerminalUI handle_question_event) will
1545
- # discard it as stale if the synchronous handler below already answers
1546
- # and clears pending — see drain_pending_question? staleness check.
1547
- emit_event(nil, { type: :question_requested, pending_question: pending })
1548
-
1549
- # If a synchronous UI handler is registered (TUI), invoke it inline on the
1550
- # SAME thread that called request_question (TerminalUI's REPL thread is the
1551
- # turn thread — no second thread exists to answer). This avoids deadlock.
1552
- # This path is TUI-specific but the surrounding emit/clear is generic, so
1553
- # any future UI that registers a sync handler gets the same guarantee.
1554
- if instance_variable_defined?(:@question_sync_handler) && @question_sync_handler
1555
- begin
1556
- sync_res = @question_sync_handler.call(pending.dup)
1557
- # Handler may have called answer_question or returned a hash/string
1558
- @question_mutex.synchronize do
1559
- if @question_answer
1560
- ans = @question_answer
1561
- @pending_question = nil
1562
- if @session
1563
- @session.pending_question = nil
1564
- begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1565
- end
1566
- emit_event(nil, { type: :question_answered, id: id, answer: ans })
1567
- return ans
1568
- end
1569
- if sync_res.is_a?(Hash) && sync_res[:selected]
1570
- # Treat returned hash as answer (handler rendered and parsed)
1571
- @question_answer = sync_res
1572
- @pending_question = nil
1573
- if @session
1574
- @session.pending_question = nil
1575
- begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1576
- end
1577
- emit_event(nil, { type: :question_answered, id: id, answer: sync_res })
1578
- return sync_res
1579
- elsif sync_res.is_a?(String) && !sync_res.strip.empty?
1580
- return sync_res
1581
- end
1582
- end
1583
- rescue StandardError => e
1584
- Log.warn(:turn, "question_handler_failed", echo: "[ask_user_question] sync handler failed: #{e.message}", error: e.class.name)
1585
- end
1586
- # Sync handler existed but did not produce an answer — do not deadlock on
1587
- # CV (no cross-thread answerer exists for synchronous UIs). Clear pending
1588
- # and return an error so the model can fallback to plain text. Generic
1589
- # observers will discard the stale question_requested via staleness check.
1590
- @question_mutex.synchronize { @pending_question = nil }
1591
- if @session
1592
- @session.pending_question = nil
1593
- begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1594
- end
1595
- return { error: "no answer", detail: "handler failed to capture selection", id: id }
1596
- end
1597
-
1598
- # Block until answered/cancelled (cross-thread path: WEB/Bridge/background worker)
1599
- answer = nil
1600
- @question_mutex.synchronize do
1601
- loop do
1602
- break if @question_answer
1603
- break if active_cancel_controller&.cancelled?
1604
- break if @pending_question.nil? || @pending_question[:status] != "pending"
1310
+ @question_desk.request(payload)
1311
+ end
1605
1312
 
1606
- # Wait with timeout to check cancel; 0.2s matches reminder poll
1607
- @question_cv.wait(0.2)
1608
- end
1609
- answer = @question_answer
1610
- # If cancelled
1611
- if active_cancel_controller&.cancelled? && answer.nil?
1612
- @pending_question = nil
1613
- if @session
1614
- @session.pending_question = nil
1615
- begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1616
- end
1617
- emit_event(nil, { type: :question_cancelled, id: id, reason: active_cancel_controller.reason.to_s })
1618
- return { error: "cancelled", reason: active_cancel_controller.reason.to_s, id: id }
1619
- end
1620
- end
1313
+ QUESTION_DISMISSED_NOTE = QuestionDesk::DISMISSED_NOTE
1621
1314
 
1622
- # Clear persisted
1623
- @question_mutex.synchronize { @pending_question = nil }
1624
- if @session
1625
- @session.pending_question = nil
1626
- begin; @session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1627
- end
1628
- if answer
1629
- emit_event(nil, { type: :question_answered, id: id, answer: answer })
1630
- answer
1631
- else
1632
- { error: "no answer", id: id }
1633
- end
1315
+ # Open a question for the UIs and wait for its answer
1316
+ # (QuestionDesk#open_question: BLOCKS until answered or cancelled).
1317
+ # @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
1318
+ # @return [Hash, String] the answer, or {error:, …}
1319
+ def open_question(fields)
1320
+ @question_desk.open_question(fields)
1634
1321
  end
1635
1322
 
1636
- # Answer the pending question (called from UI thread).
1637
- # @param id [String] pending id
1638
- # @param selected [Array<String>] values/labels
1639
- # @param freeform [String, nil]
1323
+ # Answer the pending question (called from UI thread; QuestionDesk#answer).
1640
1324
  # @return [Hash] normalized answer
1641
1325
  def answer_question(id:, selected:, freeform: nil)
1642
- sel = Array(selected).map { |v| v.to_s.strip }.reject(&:empty?)
1643
- fm = freeform.to_s.strip
1644
- fm = nil if fm.empty?
1645
- @question_mutex.synchronize do
1646
- pending = @pending_question
1647
- raise QuestionNotPending, "no pending question" unless pending
1648
- raise QuestionNotPending, "id mismatch" unless pending[:id].to_s == id.to_s
1649
- # First responder wins: after the first answer the turn thread clears
1650
- # @pending_question in a later lock block, so a second UI's answer can
1651
- # land in between and must not overwrite the first.
1652
- raise QuestionNotPending, "question already answered" if @question_answer
1653
- raise QuestionNotPending, "question #{pending[:status]}" unless pending[:status].to_s == "pending"
1654
-
1655
- opts = Array(pending[:options])
1656
- # Validate selected subset of options (value == label in v1)
1657
- invalid = sel.reject { |v| opts.include?(v) }
1658
- unless invalid.empty?
1659
- raise ArgumentError, "invalid selection: #{invalid.join(', ')} (valid: #{opts.join(', ')})"
1660
- end
1661
- if !pending[:multi_select] && sel.size > 1
1662
- raise ArgumentError, "single-select question: got #{sel.size} selections"
1663
- end
1664
- if pending[:multi_select] == false && sel.empty? && fm.nil?
1665
- raise ArgumentError, "selection required"
1666
- end
1667
- # Persist pending cleared elsewhere; just set answer
1668
- answer = { id: id.to_s, selected: sel, freeform: fm }
1669
- # Derive indices for convenience
1670
- answer[:selected_indices] = sel.map { |v| opts.index(v) }.compact
1671
- @question_answer = answer
1672
- @question_cv.broadcast
1673
- answer
1674
- end.tap do
1675
- # A human answered: the session is back in the lists (ArchiveStore).
1676
- ArchiveStore.user_input(@session&.id, state_dir: session_state_dir)
1677
- end
1326
+ @question_desk.answer(id: id, selected: selected, freeform: freeform)
1678
1327
  end
1679
1328
 
1680
1329
  def set_question_sync_handler(&block)
1681
- @question_sync_handler = block
1330
+ @question_desk.sync_handler = block
1682
1331
  end
1683
1332
 
1684
- # Cancel the pending question (e.g. /cancel, a dismiss). Announces which
1685
- # one, so every UI closes it; with none pending there is nothing to
1686
- # announce. A question already answered (the turn thread hasn't taken the
1687
- # answer yet) or already closed stays as it is: the first responder wins.
1688
- # @param id [String, nil] cancel only this question (a UI's dismiss
1689
- # names the one it showed)
1690
- # @return [Boolean] whether it was cancelled (true with none pending and
1691
- # no id, as before)
1333
+ # Cancel the pending question (QuestionDesk#cancel).
1334
+ # @return [Boolean] whether it was cancelled
1692
1335
  def cancel_question(reason = "user", id: nil)
1693
- cancelled_id = @question_mutex.synchronize do
1694
- pending = @pending_question
1695
- next unless pending
1696
- next if id && pending[:id].to_s != id.to_s
1697
- next if @question_answer || pending[:status].to_s != "pending"
1698
-
1699
- pending[:status] = "cancelled"
1700
- @question_cv.broadcast
1701
- pending[:id]
1702
- end
1703
- return id.nil? && pending_question.nil? unless cancelled_id
1704
-
1705
- emit_event(nil, { type: :question_cancelled, id: cancelled_id, reason: reason.to_s }) rescue nil
1706
- true
1336
+ @question_desk.cancel(reason, id: id)
1707
1337
  end
1708
1338
 
1709
1339
  # The system prompt for a target's loop (default: the effective model's):
@@ -1789,7 +1419,7 @@ module Samagotchi
1789
1419
 
1790
1420
  messages = @activity_mutex.synchronize { @session&.messages } || []
1791
1421
  covered = state[:covered].to_i
1792
- turns_since = Array(messages).drop(covered).count { |m| Steer.prompt?(m) }
1422
+ turns_since = Array(messages).drop(covered).count { |m| Steer.turn_prompt?(m) }
1793
1423
  { text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
1794
1424
  end
1795
1425
 
@@ -1871,8 +1501,7 @@ module Samagotchi
1871
1501
  @turn_event_sink = on_event
1872
1502
  end
1873
1503
  # The gate's context: who queued this turn, and git asked afresh.
1874
- @turn_origin = origin
1875
- @guardrail_git = Guardrails::GitInfo.new
1504
+ @guardrail_wiring.begin_turn(origin)
1876
1505
 
1877
1506
  prompt = nil if continue
1878
1507
  # For the cancel note: how long the turn ran.
@@ -1962,7 +1591,7 @@ module Samagotchi
1962
1591
  result = backend.complete(
1963
1592
  messages: messages,
1964
1593
  max_iterations: max_iterations,
1965
- on_stream_event: build_stream_event_handler(on_event),
1594
+ on_stream_event: build_stream_event_handler(on_event, cancel_controller: effective_controller),
1966
1595
  cancel_controller: effective_controller,
1967
1596
  model_name: bare_for_backend,
1968
1597
  max_tool_output_chars: max_tool_output_chars,
@@ -2385,41 +2014,17 @@ module Samagotchi
2385
2014
  end
2386
2015
 
2387
2016
  # Keep a plugin's new tool set (chi.replace_tools, from any thread)
2388
- # for the turn thread, which applies it (#apply_staged_tools!): the
2389
- # registry is read only there. A later set of the same bundle wins.
2017
+ # for the turn thread (PluginTasks#stage_tools).
2390
2018
  def stage_tools(bundle, specs, context)
2391
- @lifecycle_mutex.synchronize do
2392
- return if @shut_down
2393
-
2394
- @staged_tools[bundle] = [specs, context]
2395
- end
2396
- nil
2019
+ @plugin_tasks.stage_tools(bundle, specs, context)
2397
2020
  end
2398
2021
  private :stage_tools
2399
2022
 
2400
- # Apply the staged tool sets (#stage_tools), on the turn thread, before
2401
- # the turn's system prompt is built; the prompts are built again when
2402
- # a set changed anything. A name another source has is left out with a
2403
- # notice.
2023
+ # Apply the staged tool sets, on the turn thread, before the turn's
2024
+ # system prompt is built (PluginTasks#apply_staged_tools!).
2404
2025
  # @return [Boolean] whether the tools changed
2405
2026
  def apply_staged_tools!
2406
- staged = @lifecycle_mutex.synchronize do
2407
- taken = @staged_tools
2408
- @staged_tools = {}
2409
- taken
2410
- end
2411
- changed = false
2412
- staged.each do |bundle, (specs, context)|
2413
- result = Plugin::Api.apply_tools(@tools, bundle, specs, context)
2414
- changed ||= result[:changed]
2415
- result[:skipped].each do |why|
2416
- Log.warn(:plugins, "plugin_tool_skipped", bundle: bundle, msg: why)
2417
- hook_notify("#{why}; left out", :warn, bundle)
2418
- end
2419
- Log.info(:plugins, "plugin_tools_replaced", bundle: bundle, tools: specs.size) if result[:changed]
2420
- end
2421
- tools_changed! if changed
2422
- changed
2027
+ @plugin_tasks.apply_staged_tools!
2423
2028
  end
2424
2029
  public :apply_staged_tools!
2425
2030
 
@@ -2450,6 +2055,7 @@ module Samagotchi
2450
2055
  card: ->(**card) { show_card(**card) },
2451
2056
  steer: ->(text, source) { steer(text, source: source) },
2452
2057
  stop_turn: ->(reason, label) { hook_stop_turn(reason, label) },
2058
+ stop_generation: ->(reason, label) { hook_stop_generation(reason, label) },
2453
2059
  ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:|
2454
2060
  ask_side_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller)
2455
2061
  },
@@ -2516,74 +2122,6 @@ module Samagotchi
2516
2122
  end
2517
2123
  private :read_bundle_settings
2518
2124
 
2519
- # Build (or disable) the idle recap job. On by default: with no recap
2520
- # host or model configured it asks the session's current model on its
2521
- # host, resolved at each attempt the way a turn does (a /model switch
2522
- # counts). An explicit `recap: {host_ref:, model:}` or `{base_url:,
2523
- # model:}` pins it; an incomplete one warns and leaves recap off.
2524
- #
2525
- # Single precedence path: explicit `recap:` kwarg > Config registry
2526
- # (CLI > ENV > file > default). An explicit disable (`recap: false` as
2527
- # the kwarg or in the config file, `recap: {enabled: false}`, or
2528
- # SAMAGOTCHI_RECAP_ENABLED=false) always wins.
2529
- def build_recap(recap)
2530
- return nil if recap == false
2531
- # The TUI passes the config file's section; a worker passes nothing, so
2532
- # read it here too (a scalar `recap: false` is only seen this way).
2533
- return nil if recap.nil? && ConfigFile.recap_config == false
2534
- return nil if Samagotchi::Config.get("recap.enabled") == false
2535
-
2536
- # Normalize kwarg (TerminalUI passes recap: recap_config hash or nil)
2537
- kwarg_config = recap.is_a?(Hash) ? recap : {}
2538
-
2539
- base_url = string_config(kwarg_config, :base_url) || registry_string("recap.base_url")
2540
- host_ref = string_config(kwarg_config, :host_ref) || string_config(kwarg_config, :host) || registry_string("recap.host_ref")
2541
- model = string_config(kwarg_config, :model) || registry_string("recap.model")
2542
- label = model
2543
-
2544
- target = nil
2545
- if base_url.nil? && host_ref.nil? && model.nil?
2546
- target = -> { session_model_recap_target }
2547
- else
2548
- # If host_ref given, derive base_url (the host's OpenAI base) and its
2549
- # API key variable from the host_registry entry
2550
- api_key_env = nil
2551
- if host_ref && !host_ref.empty?
2552
- entry = @host_registry.find_entry(host_ref)
2553
- if entry
2554
- base_url = entry.openai_base_url
2555
- api_key_env = entry.api_key_env
2556
- # If model is host-qualified, extract bare model for recap client
2557
- _, bare = @host_registry.parse_qualified_model(model) if model
2558
- model = bare if bare && !bare.empty?
2559
- else
2560
- Log.warn(:recap, "host_ref_unknown", echo: "Warning: recap host_ref '#{host_ref}' not found in hosts:; recap disabled.", host_ref: host_ref)
2561
- return nil
2562
- end
2563
- end
2564
-
2565
- if base_url.to_s.strip.empty? || model.to_s.strip.empty?
2566
- Log.warn(:recap, "recap_unconfigured",
2567
- echo: "Warning: SAMAGOTCHI session recap is enabled but base_url/model are missing; recap disabled. " \
2568
- "Set recap: {host_ref:, model:} or SAMAGOTCHI_RECAP_BASE_URL and SAMAGOTCHI_RECAP_MODEL (or pass recap: {base_url:, model:}), " \
2569
- "or leave them all out to recap with the session's own model.")
2570
- return nil
2571
- end
2572
- fixed = { base_url: base_url.to_s.strip, api_key_env: api_key_env, model: model.to_s.strip, label: label.to_s.strip }
2573
- target = -> { fixed }
2574
- end
2575
-
2576
- IdleRecap.new(
2577
- engine: self,
2578
- target: target,
2579
- inactivity: recap_number_setting(kwarg_config, :inactivity, "recap.inactivity", IdleRecap::DEFAULT_INACTIVITY_SECONDS, :float),
2580
- min_user_turns: recap_number_setting(kwarg_config, :min_user_turns, "recap.min_user_turns", IdleRecap::DEFAULT_MIN_USER_TURNS, :int),
2581
- timeout: recap_number_setting(kwarg_config, :timeout, "recap.timeout", IdleRecap::DEFAULT_TIMEOUT_SECONDS, :float),
2582
- sentences: recap_sentences(string_config(kwarg_config, :sentences) || registry_string("recap.sentences")),
2583
- store: RecapStore.new(session_id_lookup: -> { @session&.id }, state_dir_lookup: -> { session_state_dir })
2584
- )
2585
- end
2586
-
2587
2125
  # The session's current model as a recap target: its host's OpenAI API
2588
2126
  # (native llama.cpp hosts serve /v1/chat/completions too), key variable
2589
2127
  # and bare model name, as a turn resolves them.
@@ -2593,37 +2131,6 @@ module Samagotchi
2593
2131
  model: target.bare_model, label: @effective_model_name.to_s }
2594
2132
  end
2595
2133
 
2596
- # recap.sentences as [min, max]; an invalid value warns and falls back to
2597
- # the default range (the recap stays on).
2598
- def recap_sentences(value)
2599
- range = IdleRecap::RecapPrompt.sentences_range(value)
2600
- return range if range
2601
-
2602
- default = IdleRecap::RecapPrompt::DEFAULT_SENTENCES
2603
- Log.warn(:recap, "sentences_invalid", echo: "Warning: invalid value for recap.sentences: #{value.to_s.inspect} — using #{default.join('-')}",
2604
- value: value.to_s)
2605
- default
2606
- end
2607
-
2608
- # Read a scalar recap setting via the Config registry (ENV > file > default).
2609
- def registry_string(key)
2610
- value = Samagotchi::Config.get(key)
2611
- value = value.to_s.strip
2612
- value.empty? ? nil : value
2613
- rescue StandardError
2614
- nil
2615
- end
2616
-
2617
- # Resolve a numeric recap setting: kwarg > Config registry > built-in default.
2618
- def recap_number_setting(kwarg_config, kwarg_key, config_key, default, numeric_type)
2619
- value = kwarg_config[kwarg_key] || kwarg_config[kwarg_key.to_s]
2620
- value = Samagotchi::Config.get(config_key) if value.nil? || value.to_s.strip.empty?
2621
- value = default if value.nil? || value.to_s.strip.empty?
2622
- numeric_type == :float ? value.to_f : value.to_i
2623
- rescue StandardError
2624
- numeric_type == :float ? default.to_f : default.to_i
2625
- end
2626
-
2627
2134
  # Build the idle reminders job. Always created (reminders are opt-in
2628
2135
  # via the agent calling register_reminder). The shared IdleScheduler
2629
2136
  # polls it and triggers synthetic turns when reminders are due.
@@ -2640,11 +2147,6 @@ module Samagotchi
2640
2147
  )
2641
2148
  end
2642
2149
 
2643
- def string_config(config, key)
2644
- value = config[key]
2645
- value.to_s.strip.empty? ? nil : value.to_s
2646
- end
2647
-
2648
2150
  # ── Event helpers ──────────────────────────────────────────────────────────
2649
2151
 
2650
2152
  # Always return a handler so raw kernel loop events reach the persistent
@@ -2669,18 +2171,27 @@ module Samagotchi
2669
2171
  # * Non-splitting profiles (nil think close, e.g. Gemma) leave the event
2670
2172
  # unchanged; the web client then falls back to raw `content`, preserving
2671
2173
  # today's behavior (no regression).
2672
- def build_stream_event_handler(on_event)
2174
+ #
2175
+ # With a :generation_progress hook registered, a Hooks::StreamWatch gets
2176
+ # each chunk's thinking and text after the UIs had it.
2177
+ def build_stream_event_handler(on_event, cancel_controller: nil)
2673
2178
  splitter = ThoughtStreamSplitter.for_profile(profile)
2674
2179
  enrich = profile.thought_close ? :always : :never
2180
+ watch = stream_watch(cancel_controller)
2675
2181
  proc do |event|
2182
+ progress = nil
2676
2183
  case event[:type]
2677
2184
  when :generation_started
2678
2185
  splitter = ThoughtStreamSplitter.for_profile(profile)
2186
+ watch&.started(event[:iteration])
2679
2187
  when :generation_chunk
2680
2188
  # The chat loop already splits its stream (reasoning arrives apart
2681
2189
  # from the answer); only raw native chunks are split here.
2682
- unless event.key?(:text)
2190
+ if event.key?(:text)
2191
+ progress = { thinking: event[:thinking], text: event[:text] }
2192
+ else
2683
2193
  delta = splitter.feed(event[:content])
2194
+ progress = { thinking: delta[:thinking], text: delta[:text] }
2684
2195
  event = event.merge(text: delta[:text], thinking: delta[:thinking]) if enrich == :always
2685
2196
  end
2686
2197
  end
@@ -2689,6 +2200,7 @@ module Samagotchi
2689
2200
  next thinking_refused(event) if event[:type] == :thinking_refused
2690
2201
 
2691
2202
  emit_event(on_event, event)
2203
+ watch.feed(**progress) if watch && progress
2692
2204
  if event[:type] == :generation_completed
2693
2205
  check_thinking_honoured(event)
2694
2206
  announce_effort_ignored
@@ -2696,6 +2208,14 @@ module Samagotchi
2696
2208
  end
2697
2209
  end
2698
2210
 
2211
+ # The turn's StreamWatch, or nil when no hook listens.
2212
+ def stream_watch(cancel_controller)
2213
+ return nil unless cancel_controller && @hooks.any?(Hooks::StreamWatch::EVENT)
2214
+
2215
+ Hooks::StreamWatch.new(hooks: @hooks, cancel_controller: cancel_controller)
2216
+ end
2217
+ private :stream_watch
2218
+
2699
2219
  def emit_event(on_event, event)
2700
2220
  # Capture used memories synchronously in the turn thread.
2701
2221
  read_names = begin