samagotchi 0.10.1 → 0.12.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +23 -4
  5. data/docs/cli.md +49 -8
  6. data/docs/configuration.md +44 -11
  7. data/docs/desktop.md +24 -2
  8. data/docs/guardrails.md +2 -1
  9. data/docs/hooks.md +3 -2
  10. data/docs/internals/context-telemetry.md +4 -2
  11. data/docs/sessions.md +3 -3
  12. data/lib/samagotchi/bootstrap/config_writer.rb +2 -2
  13. data/lib/samagotchi/bridge.rb +4 -4
  14. data/lib/samagotchi/bundles/known-names/hooks/known_names.rb +5 -3
  15. data/lib/samagotchi/bundles/known-names/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/skills/manifest.yml +3 -3
  17. data/lib/samagotchi/bundles/skills/plugin.rb +3 -23
  18. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  19. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  20. data/lib/samagotchi/client.rb +21 -1
  21. data/lib/samagotchi/config.rb +44 -40
  22. data/lib/samagotchi/context_status.rb +17 -16
  23. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  25. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  26. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  27. data/lib/samagotchi/empty_answer_retry.rb +67 -8
  28. data/lib/samagotchi/engine.rb +506 -499
  29. data/lib/samagotchi/host_registry.rb +76 -92
  30. data/lib/samagotchi/idle_reminders.rb +13 -36
  31. data/lib/samagotchi/kernel_loop.rb +352 -407
  32. data/lib/samagotchi/llm/chat_loop.rb +95 -125
  33. data/lib/samagotchi/llm/model_result.rb +14 -20
  34. data/lib/samagotchi/llm/native_backend.rb +3 -21
  35. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  36. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  37. data/lib/samagotchi/llm/usage.rb +2 -57
  38. data/lib/samagotchi/memory_bundle/installer.rb +1 -1
  39. data/lib/samagotchi/model_catalog.rb +101 -0
  40. data/lib/samagotchi/model_profile.rb +23 -4
  41. data/lib/samagotchi/model_ref.rb +60 -0
  42. data/lib/samagotchi/models_command.rb +112 -0
  43. data/lib/samagotchi/prompt_history.rb +91 -0
  44. data/lib/samagotchi/recap_setup.rb +15 -3
  45. data/lib/samagotchi/reminder_queue.rb +73 -0
  46. data/lib/samagotchi/reminder_store.rb +3 -3
  47. data/lib/samagotchi/self_report.rb +19 -7
  48. data/lib/samagotchi/send_command.rb +9 -9
  49. data/lib/samagotchi/session.rb +9 -2
  50. data/lib/samagotchi/session_commands.rb +15 -17
  51. data/lib/samagotchi/session_manager.rb +22 -9
  52. data/lib/samagotchi/sessions_command.rb +57 -51
  53. data/lib/samagotchi/steer.rb +23 -0
  54. data/lib/samagotchi/terminal_ui/attach_launcher.rb +2 -2
  55. data/lib/samagotchi/terminal_ui/attached_loop.rb +3 -6
  56. data/lib/samagotchi/terminal_ui/input_support.rb +78 -33
  57. data/lib/samagotchi/terminal_ui/reline_history_hook.rb +93 -0
  58. data/lib/samagotchi/terminal_ui.rb +16 -35
  59. data/lib/samagotchi/thinking.rb +1 -1
  60. data/lib/samagotchi/tool_response.rb +67 -0
  61. data/lib/samagotchi/tool_runner.rb +10 -7
  62. data/lib/samagotchi/tools/builtins.rb +1 -1
  63. data/lib/samagotchi/tools/delegate.rb +4 -3
  64. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  65. data/lib/samagotchi/tools/memory.rb +8 -6
  66. data/lib/samagotchi/turn_flow.rb +4 -4
  67. data/lib/samagotchi/turn_state.rb +126 -0
  68. data/lib/samagotchi/used_memories.rb +88 -0
  69. data/lib/samagotchi/version.rb +1 -1
  70. data/lib/samagotchi/vision_support.rb +4 -4
  71. data/lib/samagotchi/web/app.rb +42 -35
  72. data/lib/samagotchi/web/public/app.js +29 -2
  73. data/lib/samagotchi/web/public/data.js +9 -1
  74. data/lib/samagotchi/web/public/prompt_history.js +63 -0
  75. data/lib/samagotchi/web/session_hub.rb +0 -4
  76. data/lib/samagotchi/web/session_summary.rb +6 -6
  77. data/lib/samagotchi/worker.rb +3 -2
  78. metadata +13 -1
@@ -8,6 +8,7 @@ require "yaml"
8
8
 
9
9
  require_relative "config"
10
10
  require_relative "context_note"
11
+ require_relative "context_status"
11
12
  require_relative "steer"
12
13
  require_relative "turn_note"
13
14
  require_relative "model_profile"
@@ -24,6 +25,7 @@ require_relative "client"
24
25
  require_relative "host_registry"
25
26
  require_relative "llm/backend"
26
27
  require_relative "llm/openai_chat"
28
+ require_relative "llm/turn_settings"
27
29
  require_relative "session"
28
30
  require_relative "archive_store"
29
31
  require_relative "session_observer"
@@ -38,8 +40,11 @@ require_relative "idle_scheduler"
38
40
  require_relative "hooks"
39
41
  require_relative "guardrails"
40
42
  require_relative "reminder_store"
43
+ require_relative "reminder_queue"
41
44
  require_relative "tools/memory"
42
45
  require_relative "muted_memories"
46
+ require_relative "used_memories"
47
+ require_relative "turn_state"
43
48
  require_relative "bundle_needs"
44
49
  require_relative "model_overlay"
45
50
  require_relative "served_model"
@@ -76,20 +81,31 @@ module Samagotchi
76
81
  # @param client [Client, nil] defaults to Client.new
77
82
  # @param profile [ModelProfile, Symbol, String, nil]
78
83
  # @param session_id [String, nil] resume an existing session
79
- # @param no_interrupt [Boolean]
84
+ # @param no_interrupt [Boolean] --no-interrupt: every turn runs with
85
+ # NO_INTERRUPT_MAX_ITERATIONS, whichever loop runs it
80
86
  # @param model_name [String, nil] defaults from SAMAGOTCHI_DEFAULT_MODEL
87
+ # @param model_typed [String, nil] the name the model was given as (a session's
88
+ # model_typed: an alias), for the models: lookup; model_name by default
81
89
  # @param memories [Array<String>] explicit --memory preload list (merged with the config.yml `memories:` baseline)
82
90
  # @param muted_memories [Array<String>] --mute list: memories hidden from this session (not in the
83
91
  # prompt's index, dropped from the preloads, refused by memory_read); a mute wins over a preload
84
92
  # What memory_write answers in a scratch session.
85
93
  SCRATCH_MEMORY_WRITE = "Error: scratch session: nothing is saved"
94
+ # A turn's iteration limit with no_interrupt (the worker's own
95
+ # --no-interrupt turns pass the same).
96
+ NO_INTERRUPT_MAX_ITERATIONS = 1000
86
97
 
87
98
  # @param plugins [Boolean] false: load no bundle plugins (a throwaway Engine for a prompt)
88
99
  # @param scratch [Boolean] a `chi scratch` session: memory writes are refused, and there is no
89
100
  # delegate (a child would outlive it) nor plugin fork
90
101
  def initialize(client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, model_name: nil, memories: [], muted_memories: [], kernel: nil, recap: nil, reminders: nil,
91
- plugins: true, scratch: false)
102
+ plugins: true, scratch: false, model_typed: nil)
103
+ # The turn as other threads see it (flag, cancel controller, sink,
104
+ # steers) and the idle layer's activity clock. Built first: a plugin
105
+ # may steer or ask whether a turn runs while it loads.
106
+ @turn_state = TurnState.new(clock: -> { monotonic_now })
92
107
  @scratch = scratch
108
+ @no_interrupt = no_interrupt
93
109
  @chat_backend = nil
94
110
  @chat_backend_mutex = Mutex.new
95
111
  @host_registry = host_registry || HostRegistry.new
@@ -106,7 +122,7 @@ module Samagotchi
106
122
  # #profile_resolution decides on first need (see there), so building an
107
123
  # Engine makes no network call.
108
124
  @given_profile = profile ? ModelProfile.normalize(profile) : nil
109
- @typed_model_name = @default_model_name
125
+ @typed_model_name = model_typed.to_s.strip.empty? ? @default_model_name : model_typed.to_s.strip
110
126
  @profile_resolution = nil
111
127
  # Ensure the built-in system bundle is installed (lazy, warn-only).
112
128
  # This is the single seam for both TUI and non-TUI (web/worker) paths.
@@ -182,48 +198,41 @@ module Samagotchi
182
198
  @shut_down = false
183
199
  load_plugins if plugins
184
200
  guardrail_rules
185
- # Use the KernelLoop's reminder_store if provided (TerminalUI path),
186
- # otherwise create our own (SessionManager/one-shot paths). This ensures
187
- # tool calls via KernelLoop and reminder injection via Engine read/write
188
- # the same store.
189
- if kernel && kernel.respond_to?(:reminder_store)
190
- @reminder_store = kernel.reminder_store
191
- else
192
- @reminder_store = ReminderStore.new
193
- end
201
+ # The Engine owns the reminder store; its kernel (built here, or a
202
+ # spec's) gets it, so tool calls via KernelLoop and reminder injection
203
+ # via Engine read/write the same store.
204
+ @reminder_store = ReminderStore.new
205
+ # The due reminders on their way into a turn, and the names the idle
206
+ # tick queued for a reminder turn.
207
+ @reminder_queue = ReminderQueue.new(store: @reminder_store)
194
208
  # Build the idle reminders detector (wired to reminder_store)
195
209
  callback = reminders.is_a?(Hash) && reminders[:callback] ? reminders[:callback] : nil
196
210
  @reminders = build_reminders(auto_turn_callback: callback)
197
211
  # Track whether this is the first turn in the session (for session_start event)
198
212
  @first_turn = true
199
- @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, no_interrupt: no_interrupt, hooks: @hooks, reminder_store: @reminder_store,
213
+ # A given kernel (specs) gets the Engine's store, hooks and tools, as
214
+ # the one built here does.
215
+ @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, hooks: @hooks, reminder_store: @reminder_store,
200
216
  tools: @tools)
217
+ @kernel.reminder_store = @reminder_store
218
+ @kernel.hooks = @hooks
219
+ @kernel.tools = @tools
201
220
  sync_kernel_client!
202
- @model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
203
- @kernel.sync_model_key!(@model_key) if @kernel.respond_to?(:sync_model_key!)
221
+ sync_model_key!
204
222
  # The mutes never change during a session, so no re-sync: the kernel's
205
223
  # memory_read guard reads the same list for every turn.
206
224
  @muted_memory_names = MutedMemories.normalize_list(muted_memories)
207
- @kernel.muted_memory_names = @muted_memory_names if @kernel.respond_to?(:muted_memory_names=)
225
+ @kernel.muted_memory_names = @muted_memory_names
208
226
  # Keep kernel client in sync with active host via setter
209
227
  @kernel_client_synced = false
210
- # Engine owns hooks; if a kernel was supplied externally (TUI path) propagate
211
- # the Engine's registry so all UIs reuse the same instance. Without this the
212
- # TUI's KernelLoop fires with nil hooks and before_generation/after_generation
213
- # etc. never fire in interactive mode.
214
- if kernel && @kernel.respond_to?(:hooks=)
215
- @kernel.hooks = @hooks
216
- end
217
- # Likewise its tools: the REPL builds its kernel before the Engine.
218
- @kernel.tools = @tools if kernel && @kernel.respond_to?(:tools=)
219
228
  # ask_user_question blocks on the Engine's question flow (TUI/Web answer it).
220
- @kernel.question_handler = proc { |payload| request_question(payload) } if @kernel.respond_to?(:question_handler=)
229
+ @kernel.question_handler = proc { |payload| request_question(payload) }
221
230
  # Every tool call asks this gate first. The kernel is never rebuilt, so
222
231
  # it holds across model switches.
223
232
  self.guardrail_state_dir = Session.default_state_dir
224
233
  # list_sessions and send_note speak for whichever session runs now.
225
- @kernel.peers = PeerView.new(self) if @kernel.respond_to?(:peers=)
226
- @kernel.guardrail_gate = @guardrail_wiring.gate if @kernel.respond_to?(:guardrail_gate=)
234
+ @kernel.peers = PeerView.new(self)
235
+ @kernel.guardrail_gate = @guardrail_wiring.gate
227
236
  # What a hook can do beyond reading its event (event[:notify],
228
237
  # event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
229
238
  @hooks.runtime = hook_runtime
@@ -238,29 +247,18 @@ module Samagotchi
238
247
  @session = nil
239
248
  @session_observer = SessionObserver.new
240
249
  @metrics = SessionMetrics.new
241
- @used_memory_names = []
242
- @used_memory_mutex = Monitor.new
250
+ @used_memories = UsedMemories.new
243
251
  # Hydrate from resumed session if present
244
- if @resume_session && @resume_session.respond_to?(:used_memory_names)
245
- @used_memory_names = Array(@resume_session.used_memory_names).map(&:to_s).reject(&:empty?).uniq
252
+ if @resume_session
253
+ @used_memories.absorb(@resume_session)
246
254
  @session = @resume_session
247
255
  end
248
- # Shared inactivity clock + turn-running flag for the idle subsystems
249
- # (session recap + reminders), all polled by the shared IdleScheduler.
250
- # `record_activity` is the single seam every UI calls (run_turn itself,
251
- # the REPL on keystrokes and after a reminder turn), so the idle
252
- # layer's clock is identical across UIs.
253
- @activity_mutex = Monitor.new
254
- @last_activity_at = monotonic_now
255
- @activity_seq = 0
256
- @turn_running = false
257
- @active_cancel_controller = nil
258
- # Plugin steers ({text:, source:}) waiting for the running turn's next
259
- # boundary (#steer); guarded by @activity_mutex.
260
- @steers = []
261
- # Reminder names the interactive REPL's IdleReminders callback marked due;
262
- # the REPL polls them to decide when to run a synthetic reminder turn.
263
- @due_reminder_names = []
256
+ # The idle subsystems' (session recap + reminders) inactivity clock
257
+ # starts now, after the plugins loaded. `record_activity` is the
258
+ # single seam every UI calls (run_turn itself, the REPL on keystrokes
259
+ # and after a reminder turn), so the idle layer's clock is identical
260
+ # across UIs.
261
+ @turn_state.restart_clock!
264
262
  @recap = RecapSetup.build(recap, engine: self, host_registry: @host_registry,
265
263
  session_target: -> { session_model_recap_target },
266
264
  session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
@@ -290,37 +288,29 @@ module Samagotchi
290
288
  backend_for(@host_registry.resolve(@effective_model_name))
291
289
  end
292
290
 
293
- # Record that activity happened (user input or a completed turn). Shared,
294
- # mutex-guarded seam for the idle recap detector. Idempotent-ish: each call
295
- # advances both the last-activity timestamp and the activity sequence.
291
+ # Record that activity happened (user input or a completed turn): the
292
+ # idle jobs' seam (TurnState#record_activity). Each call advances both
293
+ # the last-activity timestamp and the activity sequence.
296
294
  # @param now [Float, nil] injectable monotonic time (defaults to now)
297
295
  def record_activity(now = nil)
298
- @activity_mutex.synchronize do
299
- @last_activity_at = now ? now.to_f : monotonic_now
300
- @activity_seq += 1
301
- end
296
+ @turn_state.record_activity(now)
302
297
  end
303
298
 
304
299
  # @return [Float] monotonic seconds of the last recorded activity
305
300
  def last_activity_at
306
- @activity_mutex.synchronize { @last_activity_at }
301
+ @turn_state.last_activity_at
307
302
  end
308
303
 
309
304
  # @return [Integer] monotonically-increasing activity counter (advanced by
310
305
  # #record_activity; lets the idle detector summarize once per idle window)
311
306
  def activity_seq
312
- @activity_mutex.synchronize { @activity_seq }
307
+ @turn_state.activity_seq
313
308
  end
314
309
 
315
- # Mark whether a turn is currently running (shared with the idle detector so
316
- # a recap never fires, or renders, while the model is generating).
317
- def set_turn_running(running)
318
- @activity_mutex.synchronize { @turn_running = running }
319
- end
320
-
321
- # @return [Boolean] true while a turn is in flight
310
+ # @return [Boolean] true while a turn is in flight (the idle jobs never
311
+ # fire, nor render, while the model is generating)
322
312
  def turn_running?
323
- @activity_mutex.synchronize { @turn_running }
313
+ @turn_state.running?
324
314
  end
325
315
 
326
316
  # Put +text+ into the running turn, as a UI's steering does: it joins the
@@ -331,15 +321,7 @@ module Samagotchi
331
321
  # the turn ends first, it is dropped (logged).
332
322
  # @return [Boolean] whether it was queued
333
323
  def steer(text, source:)
334
- text = text.to_s.strip
335
- return false if text.empty?
336
-
337
- @activity_mutex.synchronize do
338
- return false unless @turn_running
339
-
340
- @steers << { text: text, source: source.to_s }
341
- end
342
- true
324
+ @turn_state.steer(text, source: source)
343
325
  end
344
326
 
345
327
  # The drain a turn's loop gets: the caller's lines (a UI's steering; nil
@@ -356,11 +338,7 @@ module Samagotchi
356
338
  private :turn_drain
357
339
 
358
340
  def take_steers(drop)
359
- steers = @activity_mutex.synchronize do
360
- taken = @steers
361
- @steers = []
362
- taken
363
- end
341
+ steers = @turn_state.take_steers
364
342
  return steers unless drop
365
343
 
366
344
  log_dropped_steers(steers, "answered")
@@ -377,41 +355,37 @@ module Samagotchi
377
355
 
378
356
  # @return [Array<String>] reminder names queued for a synthetic REPL turn
379
357
  def due_reminder_names
380
- @activity_mutex.synchronize { @due_reminder_names.dup }
358
+ @reminder_queue.pending_names
381
359
  end
382
360
 
383
361
  # Queue reminder names for a synthetic REPL turn (IdleReminders callback).
384
362
  def note_due_reminders(names)
385
- @activity_mutex.synchronize { @due_reminder_names = Array(names).dup }
363
+ @reminder_queue.note_pending(names)
386
364
  end
387
365
 
388
366
  def clear_due_reminder_names!
389
- @activity_mutex.synchronize { @due_reminder_names = [] }
367
+ @reminder_queue.clear_pending!
390
368
  end
391
369
 
392
370
  # @return [CancellationController, nil] active turn's cancellation controller
393
371
  def active_cancel_controller
394
- @activity_mutex.synchronize { @active_cancel_controller }
372
+ @turn_state.controller
395
373
  end
396
374
 
397
375
  # Cancel the currently running turn, if any.
398
376
  # @param reason [Symbol] cancellation reason
399
377
  # @return [Boolean] whether a cancellation was triggered
400
378
  def cancel_current_turn!(reason = :manual)
401
- ctrl = active_cancel_controller
402
- return false unless ctrl
403
-
404
- ctrl.cancel!(reason)
379
+ @turn_state.cancel!(reason)
405
380
  end
406
381
 
407
382
  # Snapshot the current session messages as a JSON string for the idle
408
- # recap. Reads the array reference under the mutex (a single atomic
409
- # pointer read in CRuby) then serializes a dup'd copy OUTSIDE the lock so
410
- # the brief serialization never blocks the main turn thread. Never mutates
411
- # session.messages.
383
+ # recap. The array is never mutated in place (a turn or a note replaces
384
+ # it), so the reference read here is a consistent snapshot; a dup'd
385
+ # copy is serialized. Never mutates session.messages.
412
386
  # @return [String] JSON array of the messages
413
387
  def messages_json_for_recap
414
- snapshot = @activity_mutex.synchronize { @session&.messages }
388
+ snapshot = @session&.messages
415
389
  return "[]" if snapshot.nil?
416
390
 
417
391
  JSON.generate(AnswerDisplay.strip_all(snapshot).map(&:dup))
@@ -426,7 +400,30 @@ module Samagotchi
426
400
 
427
401
  # @return [SessionMetrics] the per-session analytics collector
428
402
  attr_reader :metrics
429
- attr_reader :default_model_name, :effective_model_name
403
+ # effective_model_name: the model as given (maybe an alias; HostRegistry#resolve
404
+ # applies it); typed_model_name: as typed at start or at the last /model.
405
+ attr_reader :default_model_name, :effective_model_name, :typed_model_name
406
+
407
+ # The resolved ref a session stores for the effective model (ModelRef#ref:
408
+ # the alias applied, "host:id" when it names a host).
409
+ def effective_model_ref
410
+ model_ref_for(@effective_model_name)
411
+ end
412
+
413
+ # +name+'s resolved ref (ModelRef#ref).
414
+ def model_ref_for(name)
415
+ @host_registry.model_ref(name).ref
416
+ end
417
+
418
+ # Writes the effective model to +session+: the resolved ref, and the
419
+ # name as typed when that differs (model_typed: an alias, for the
420
+ # models: lookup after a resume).
421
+ def store_model!(session)
422
+ ref = effective_model_ref
423
+ session.model_name = ref
424
+ session.model_typed = @typed_model_name.to_s == ref ? nil : @typed_model_name
425
+ session
426
+ end
430
427
 
431
428
  # The effective model's key (ModelOverlay.key_for its bare name): memory
432
429
  # overlays, guardrail rules' models:.
@@ -453,10 +450,23 @@ module Samagotchi
453
450
  # built-ins, and the ones bundle plugins add
454
451
  attr_reader :command_registry
455
452
 
453
+ # The model id sent for +full_ref+ (its alias applied, the host prefix off).
456
454
  def bare_model_name(full_ref)
457
455
  @host_registry.bare_name(full_ref)
458
456
  end
459
457
 
458
+ # The memory overlay key follows the model sent (ModelOverlay.key_for
459
+ # its id). A model typed as an alias used to key overlays by the alias
460
+ # (before aliases were applied there): that key is read when the
461
+ # target's overlay is missing (KernelLoop#sync_model_key!).
462
+ def sync_model_key!
463
+ @model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
464
+ typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(@typed_model_name).last)
465
+ fallback = typed_key == @model_key ? nil : typed_key
466
+ @kernel.sync_model_key!(@model_key, fallback: fallback)
467
+ end
468
+ private :sync_model_key!
469
+
460
470
  # The effective model's bare name for the guardrails' models: rules;
461
471
  # nil without a model name.
462
472
  def guardrail_model_name
@@ -469,7 +479,7 @@ module Samagotchi
469
479
  def sync_kernel_client!
470
480
  target = @host_registry.resolve(@effective_model_name)
471
481
  @client = target.client
472
- @kernel.client = target.client if @kernel.respond_to?(:client=) && @kernel.client != target.client
482
+ @kernel.client = target.client if @kernel.client != target.client
473
483
  backend_for(target)
474
484
  end
475
485
 
@@ -567,11 +577,7 @@ module Samagotchi
567
577
  # A plugin's init task (chi.init): never a running turn's event.
568
578
  return announce(card.merge(in_turn: false)) && card[:id] if current_init_task
569
579
 
570
- sink = nil
571
- in_turn = @activity_mutex.synchronize do
572
- sink = @turn_event_sink
573
- @turn_running
574
- end
580
+ in_turn, sink = @turn_state.in_turn_sink
575
581
  card[:in_turn] = in_turn ? true : false
576
582
  if in_turn
577
583
  emit_event(sink, card)
@@ -762,27 +768,29 @@ module Samagotchi
762
768
 
763
769
  # ── Model switching ────────────────────────────────────────────────────────
764
770
 
765
- def switch_model!(model_name, persist_default: false)
766
- # Resolve alias first (alias may point to qualified ref)
767
- aliased = ConfigFile.resolve_model_alias(model_name)
768
- resolved = ModelProfile.check_host!(ModelProfile.required_model_name(aliased), hosts: @host_registry.entries)
771
+ # @param typed [String, nil] the name the model was typed as when
772
+ # +model_name+ is a stored ref (a resumed session's model_typed)
773
+ # @return [String] the resolved ref the session stores (ModelRef#ref)
774
+ def switch_model!(model_name, persist_default: false, typed: nil)
775
+ # The alias is applied where the model is resolved (HostRegistry#resolve),
776
+ # once; checked here: an unknown host, an alias naming another host.
777
+ resolved = ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries)
769
778
  @effective_model_name = resolved
770
- bare = bare_model_name(resolved)
771
- @typed_model_name = model_name
779
+ @typed_model_name = typed.to_s.strip.empty? ? model_name : typed.to_s.strip
772
780
  # A profile given to .new was for the starting model.
773
781
  @given_profile = nil
774
782
  @profile_resolution = nil
775
- @model_key = ModelOverlay.key_for(bare)
776
- @kernel.sync_model_key!(@model_key) if @kernel.respond_to?(:sync_model_key!)
783
+ sync_model_key!
777
784
  @prompt_builder.reset!
778
785
  sync_kernel_client!
779
- @client.invalidate_context_window! if @client.respond_to?(:invalidate_context_window!)
786
+ @client.invalidate_context_window!
780
787
  @metrics.forget_model_reports!
788
+ ref = effective_model_ref
781
789
  if persist_default
782
- ConfigFile.write_default_model!(resolved)
783
- @default_model_name = resolved
790
+ ConfigFile.write_default_model!(ref)
791
+ @default_model_name = ref
784
792
  end
785
- resolved
793
+ ref
786
794
  end
787
795
 
788
796
  # ── Hooks API ──────────────────────────────────────────────────────────────
@@ -814,53 +822,17 @@ module Samagotchi
814
822
  end
815
823
 
816
824
  # ── Reminders API ────────────────────────────────────────────────────────────
817
-
818
- # Get and inject all due reminders into the conversation. Called at the top
819
- # of run_turn (before set_turn_running(true)) so injection happens on the
820
- # main thread, serially with the turn — no TOCTOU race.
821
- #
822
- # Returns the array of due reminder hashes (may be empty). Injects a
823
- # [SYSTEM: REMINDERS DUE] message into the conversation when there are
824
- # Get due reminders, inject them into the provided messages array as
825
- # [SYSTEM: REMINDERS DUE], and atomically mark all as fired under one
826
- # ReminderStore lock.
827
- #
828
- # This is the canonical method for reminder injection, called from
829
- # Engine#run_turn after system prompt construction so the reminder text
830
- # is never overwritten.
831
825
  #
832
- # @param messages [Array<Hash>] the conversation messages (mutated in place)
833
- # @return [Array<Hash>] [{name:, description:, interval_minutes:}, ...]
834
- def collect_due_reminders(messages)
835
- due = @reminders&.due_reminders
836
- return [] if due.nil? || due.empty?
837
-
838
- reminder_lines = due.map do |r|
839
- " #{r[:name]}: #{r[:description]} (interval: #{r[:interval_minutes]}m)"
840
- end.join("\n")
841
- reminder_text = "[SYSTEM: REMINDERS DUE]\n#{reminder_lines}\n[END REMINDERS]"
842
- # Append as a tail message to preserve prefix KV cache. Mutating the
843
- # head system prompt invalidates the cache for the entire conversation
844
- # (prompt re-evaluated every interval). A tail append keeps the prefix
845
- # intact — only the new reminder suffix is evaluated. Mirrors the
846
- # context guidance line KernelLoop#run appends (ContextStatus#take_guidance).
847
- messages << { role: "system", content: reminder_text }
848
- # Atomically mark all due as fired under one lock and clear the
849
- # IdleReminders latch so the next interval can be detected.
850
- @reminder_store&.mark_fired_batch(due.map { |r| r[:name] })
851
- @reminders&.clear_due
852
- # Also clear the REPL's due-reminder queue (#note_due_reminders).
853
- # Without this, a normal-turn injection leaves a stale entry, causing
854
- # the next top-of-loop synthetic turn to fire empty and duplicate output.
855
- clear_due_reminder_names!
856
- due
857
- end
826
+ # Each turn injects the reminders due then (#turn_messages,
827
+ # ReminderQueue#inject!). Between turns the idle tick (IdleReminders)
828
+ # finds them due and its callback queues their names
829
+ # (#note_due_reminders); the worker and the REPL run a reminder turn
830
+ # (a continue turn) for them.
858
831
 
859
832
  # @return [Boolean] whether any reminder is due now (the store's view,
860
- # which #collect_due_reminders would inject), regardless of the REPL queue
833
+ # which a turn would inject), regardless of the queued names
861
834
  def reminders_due?
862
- due = @reminders&.due_reminders
863
- !(due.nil? || due.empty?)
835
+ @reminder_queue.due?
864
836
  end
865
837
 
866
838
  # @return [ReminderStore] the reminder store for inspection
@@ -897,9 +869,9 @@ module Samagotchi
897
869
  # no turn yet (it woke after an idle exit): nil when either count is
898
870
  # unknown or context.status is off.
899
871
  def saved_context_status(context)
900
- return nil unless context && @kernel.respond_to?(:context_display)
872
+ return nil unless context
901
873
 
902
- @kernel.context_display(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
874
+ ContextStatus.new.display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
903
875
  end
904
876
 
905
877
  # The effective model is on a chat host (api: openai), whose loop uses
@@ -964,11 +936,11 @@ module Samagotchi
964
936
  event_seq: @session_observer&.event_count,
965
937
  metrics: metrics,
966
938
  pending_question: @question_desk.pending,
967
- used_memory_names: @used_memory_mutex.synchronize { @used_memory_names.dup },
939
+ used_memory_names: @used_memories.names,
968
940
  preloaded_memory_names: preloaded_memory_names,
969
941
  muted_memory_names: @muted_memory_names.dup,
970
942
  parent_id: @session&.parent_id,
971
- model_name: @effective_model_name,
943
+ model_name: effective_model_ref,
972
944
  served_model: served_pair[0],
973
945
  served_model_for: served_pair[1],
974
946
  context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
@@ -980,7 +952,7 @@ module Samagotchi
980
952
 
981
953
  # @return [Array<String>] deduped used memory names (thread-safe copy)
982
954
  def used_memory_names
983
- @used_memory_mutex.synchronize { @used_memory_names.dup }
955
+ @used_memories.names
984
956
  end
985
957
 
986
958
  # @return [Array<String>] the memories hidden from this session (normalized names)
@@ -999,73 +971,6 @@ module Samagotchi
999
971
  MutedMemories.muted?(name, @muted_memory_names)
1000
972
  end
1001
973
 
1002
- def add_used_memory_names(names)
1003
- return if names.nil? || Array(names).empty?
1004
-
1005
- @used_memory_mutex.synchronize do
1006
- Array(names).each do |n|
1007
- v = n.to_s.strip
1008
- next if v.empty?
1009
- next if @used_memory_names.include?(v)
1010
-
1011
- @used_memory_names << v
1012
- end
1013
- end
1014
- end
1015
-
1016
- def sync_used_memories_from_session(session)
1017
- return unless session && session.respond_to?(:used_memory_names)
1018
-
1019
- add_used_memory_names(Array(session.used_memory_names))
1020
- end
1021
-
1022
- def memory_name_from_tool_call(call)
1023
- return nil unless call.is_a?(Hash)
1024
-
1025
- tool = call[:name].to_s
1026
- case tool
1027
- when Tools::MemoryRead::NAME
1028
- content = call[:content].to_s.strip
1029
- return nil if content.empty?
1030
-
1031
- # comma-separated names
1032
- content.split(",").map { |s| normalize_memory_name(s) }.compact
1033
- when Tools::Read::NAME
1034
- path = call[:content].to_s.strip.tr("\\", "/")
1035
- return nil if path.empty?
1036
- return nil unless path.match?(/memories\/.+\.md\z/)
1037
-
1038
- normalize_memory_name(path)
1039
- else
1040
- nil
1041
- end
1042
- end
1043
-
1044
- def normalize_memory_name(raw)
1045
- v = raw.to_s.strip
1046
- return nil if v.empty?
1047
-
1048
- # basename without .md, handle comma already split
1049
- base = File.basename(v, ".md").strip
1050
- base.empty? ? nil : base
1051
- end
1052
-
1053
- # A memory read (memory_read, or read of a memories/*.md file) as it
1054
- # starts: its names join used_memory_names.
1055
- # @return [Array<String>, nil] the names this call read, nil for any other event
1056
- def capture_used_memory_from_event(event)
1057
- return unless event.is_a?(Hash) && event[:type] == :tool_call_started
1058
-
1059
- call = event[:call].is_a?(Hash) ? event[:call] : {}
1060
- names = memory_name_from_tool_call(call)
1061
- # A refused read of a muted memory is not a use of it.
1062
- names = Array(names).reject { |n| memory_muted?(n) }
1063
- return if names.empty?
1064
-
1065
- add_used_memory_names(names)
1066
- names
1067
- end
1068
-
1069
974
  # ── Guardrails ─────────────────────────────────────────────────────────────
1070
975
 
1071
976
  # Who can answer an approval: :repl, :worker or :non_interactive (the
@@ -1088,7 +993,7 @@ module Samagotchi
1088
993
 
1089
994
  # The metrics load the session's saved records from its state dir, once.
1090
995
  def bind_metrics(session)
1091
- return unless session.respond_to?(:id) && session.id
996
+ return unless session&.id
1092
997
 
1093
998
  @metrics.state_dir = session_state_dir
1094
999
  @metrics.session_id = session.id
@@ -1178,6 +1083,38 @@ module Samagotchi
1178
1083
  end
1179
1084
  private :record_last_turn
1180
1085
 
1086
+ # How every turn ends (completed, canceled, failed): the session gets
1087
+ # what the turn kept, goes idle and records the ending, and the end
1088
+ # event is announced, as one step of the event log (a snapshot taken
1089
+ # meanwhile, the Bridge's, shows the turn either in progress or in the
1090
+ # messages, never both or neither). Then the metrics are persisted.
1091
+ # The block runs in that step with the turn's seconds, once, and
1092
+ # returns [the messages to keep (nil: leave the session's), the end
1093
+ # event]. +save:+ also saves the session (a failed turn: a worker exits
1094
+ # after it). The disk writes come after the step: every emitter waits
1095
+ # for the event lock.
1096
+ def end_turn(turn, outcome, save: false)
1097
+ seconds = turn.elapsed
1098
+ synchronize_events do
1099
+ kept, event = yield(seconds)
1100
+ replace_session_messages(turn.session, kept) if kept
1101
+ turn.session.status = Session::STATUS_IDLE
1102
+ record_last_turn(turn.session, outcome, seconds, turn.origin)
1103
+ emit_event(turn.on_event, turn.tag(event))
1104
+ end
1105
+ turn.ended = true
1106
+ save_quietly(turn.session) if save
1107
+ @metrics.persist(state_dir: session_state_dir)
1108
+ end
1109
+ private :end_turn
1110
+
1111
+ def save_quietly(session)
1112
+ session.save(state_dir: session_state_dir)
1113
+ rescue StandardError
1114
+ nil
1115
+ end
1116
+ private :save_quietly
1117
+
1181
1118
  # Keep what the after_turn hooks presented as the answer's `display`
1182
1119
  # and tell the observers (:answer_display), after the turn_completed
1183
1120
  # they already had: the web re-reads the answer then. A turn_completed
@@ -1231,11 +1168,7 @@ module Samagotchi
1231
1168
  notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level }
1232
1169
  return hold_load_event(notice) && nil if @loading_plugins
1233
1170
 
1234
- sink = nil
1235
- in_turn = @activity_mutex.synchronize do
1236
- sink = @turn_event_sink
1237
- @turn_running
1238
- end
1171
+ in_turn, sink = @turn_state.in_turn_sink
1239
1172
  if anytime_thread?
1240
1173
  announce_anytime(notice.merge(between_turns: true))
1241
1174
  elsif current_init_task
@@ -1376,10 +1309,10 @@ module Samagotchi
1376
1309
  def session=(session)
1377
1310
  @session = session
1378
1311
  # One session per REPL/worker process: its records carry this sid.
1379
- Log.session_id = session.id if session.respond_to?(:id) && session.id
1312
+ Log.session_id = session.id if session&.id
1380
1313
  # A woken worker's /stats and status line count the turns before it.
1381
1314
  bind_metrics(session)
1382
- sync_used_memories_from_session(session)
1315
+ @used_memories.absorb(session)
1383
1316
  end
1384
1317
 
1385
1318
  # @return [IdleRecap, nil] the idle recap detector, or nil when disabled
@@ -1407,7 +1340,7 @@ module Samagotchi
1407
1340
  state = @recap&.state
1408
1341
  return nil unless state
1409
1342
 
1410
- messages = @activity_mutex.synchronize { @session&.messages } || []
1343
+ messages = @session&.messages || []
1411
1344
  covered = state[:covered].to_i
1412
1345
  turns_since = Array(messages).drop(covered).count { |m| Steer.turn_prompt?(m) }
1413
1346
  { text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
@@ -1429,10 +1362,27 @@ module Samagotchi
1429
1362
  self
1430
1363
  end
1431
1364
 
1365
+ # One turn's values, for #run_turn and its endings. Per turn and
1366
+ # single-threaded: the cross-thread turn state (the cancel slot, the
1367
+ # sink, steers) stays on the Engine.
1368
+ # +ended+: the end event is out (what follows is post-turn work).
1369
+ # +settings+: the kernel's LLM::TurnSettings, made in #prepare_turn and
1370
+ # set on the kernel in #generate.
1371
+ Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
1372
+ :settings) do
1373
+ # The turn's boundary events carry the origin only when there is one,
1374
+ # so payloads stay unchanged for callers that don't pass it.
1375
+ def tag(event) = origin ? event.merge(origin: origin) : event
1376
+
1377
+ # Seconds since the turn started (the cancel note says how long it ran).
1378
+ def elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
1379
+ end
1380
+ private_constant :Turn
1381
+
1432
1382
  # Run a single turn with event emission.
1433
1383
  #
1434
1384
  # Builds the system prompt + user messages, runs the kernel loop with
1435
- # event forwarding, and returns a KernelLoop::Result.
1385
+ # event forwarding, and returns its LLM::ModelResult.
1436
1386
  #
1437
1387
  # @param session [Session] the session to operate on
1438
1388
  # @param prompt [String] user input
@@ -1441,7 +1391,7 @@ module Samagotchi
1441
1391
  # @param cancel_controller [CancellationController, nil]
1442
1392
  # @param max_tool_output_chars [Integer, nil] per-output char cap for the
1443
1393
  # :tool_call_completed event's `output:` (nil → max_tool_output_chars)
1444
- # @return [KernelLoop::Result]
1394
+ # @return [LLM::ModelResult]
1445
1395
  # @param pending_input [#call, nil] optional drain proc returning
1446
1396
  # Array<String> of steering messages queued while the turn runs; drained
1447
1397
  # by the agentic loop at iteration boundaries (see KernelLoop#run).
@@ -1460,283 +1410,334 @@ module Samagotchi
1460
1410
  #
1461
1411
  # An Interrupt (SIGINT) cancels the turn: the pre-turn conversation plus
1462
1412
  # the prompt is kept in the session and :turn_canceled is emitted, then the
1463
- # Interrupt is re-raised so the caller still decides whether to exit. Any
1413
+ # Interrupt is re-raised so the caller still decides whether to exit. One
1414
+ # after the turn's end event (in its after_turn/session_end hooks) is
1415
+ # only re-raised: the turn already ended. Any
1464
1416
  # other error (e.g. an LLM::ProviderError) emits :turn_failed and
1465
1417
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
1466
1418
  # one-line summary:.
1467
1419
  def run_turn(session, prompt, on_event: nil, max_iterations: 100, cancel_controller: nil, max_tool_output_chars: nil, pending_input: nil, continue: false, origin: nil,
1468
1420
  images: [])
1421
+ turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
1422
+ continue: continue)
1423
+ begin
1424
+ # A Stop cuts the /props probes this thread makes for the turn
1425
+ # (window, served model, vision) instead of waiting their timeout.
1426
+ probe_cancel_before = Client.swap_probe_cancel(turn.controller)
1427
+ prepare_turn(turn, continue ? [] : images)
1428
+ result = generate(turn, max_iterations: max_iterations, max_tool_output_chars: max_tool_output_chars,
1429
+ pending_input: pending_input)
1430
+ publish_used_memories(session, on_event)
1431
+ complete_turn(turn, result)
1432
+ result
1433
+ rescue Interrupt
1434
+ # After the end event (in the after_turn/session_end hooks) the
1435
+ # turn is over: no second ending, the answer stays.
1436
+ raise if turn.ended
1437
+
1438
+ turn.controller.cancel!(:ctrl_c)
1439
+ end_turn(turn, "canceled") do |seconds|
1440
+ [ctrl_c_messages(turn, seconds), { type: :turn_canceled, cancellation_reason: :ctrl_c, duration_ms: (seconds * 1000).round }]
1441
+ end
1442
+ raise
1443
+ rescue StandardError => e
1444
+ # Keep what the turn got to (the prompt plus the loop's completed
1445
+ # tool iterations) like a cancel does, and save it: a worker exits
1446
+ # after a failed turn. The REPL still rolls back to its checkpoint.
1447
+ end_turn(turn, "failed", save: true) { |seconds| [failed_messages(turn, e), failed_event(e, seconds)] }
1448
+ raise
1449
+ ensure
1450
+ release_turn(probe_cancel_before)
1451
+ end
1452
+ end
1453
+
1454
+ # The turn's state before anything can fail it: the session running,
1455
+ # the turn flag, its cancel controller and sink. The cross-thread state
1456
+ # is set here and cleared in #release_turn only.
1457
+ def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:)
1469
1458
  # Track the active session for recap and status snapshot.
1470
1459
  @session = session
1471
1460
  # status is turn state: running now, idle again before the turn's end
1472
1461
  # is announced, so a UI reacting to that event reads the new state.
1473
1462
  session.status = Session::STATUS_RUNNING
1474
- sync_used_memories_from_session(session)
1475
- # Mark the turn running before generating so the idle recap detector does
1476
- # not fire (or render an invalidated recap) while the model is working,
1477
- # and drop a recap already in flight: the turn makes it stale.
1478
- set_turn_running(true)
1463
+ @used_memories.absorb(session)
1464
+ # Provide a cancellable controller for this turn (cross-process cancel via file flag)
1465
+ effective_controller = cancel_controller || CancellationController.new
1466
+ # The turn runs, with its controller and sink, in one step: a Stop
1467
+ # or a plugin's card from another thread never sees a running turn
1468
+ # without them. The idle recap detector does not fire (or render an
1469
+ # invalidated recap) while the model is working. A hook's notice goes
1470
+ # where the turn's events go (the REPL renders only its sink; a
1471
+ # worker's observers carry it to the bridge).
1472
+ @turn_state.begin!(controller: effective_controller, sink: on_event)
1473
+ # Drop a recap already in flight: the turn makes it stale.
1479
1474
  @recap&.invalidate!
1475
+ # The gate's context: who queued this turn, and git asked afresh.
1476
+ @guardrail_wiring.begin_turn(origin)
1477
+
1478
+ Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
1479
+ controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC))
1480
+ end
1481
+ private :begin_turn
1482
+
1483
+ # Everything before the model is asked: :turn_started, the kernel's
1484
+ # per-turn settings, the plugins' setup, the session_start/before_turn
1485
+ # hooks, then the messages to send (#turn_messages).
1486
+ def prepare_turn(turn, images)
1487
+ session = turn.session
1488
+ image_refs, image_error = turn_image_refs(session, images)
1489
+ # Emit turn_started event
1490
+ bind_metrics(session)
1491
+ turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt }
1492
+ turn_started[:continue] = true if turn.continue
1493
+ turn_started[:images] = image_refs unless image_refs.empty?
1494
+ emit_event(turn.on_event, turn.tag(turn_started))
1495
+ raise image_error if image_error
1496
+
1480
1497
  # Ask the server for its window again each turn (one short /props GET,
1481
1498
  # cached across the turn's generations): a restart with another -c
1482
- # between turns raises no error that would drop the cache.
1483
- @client.invalidate_context_window! if @client.respond_to?(:invalidate_context_window!)
1499
+ # between turns raises no error that would drop the cache. The
1500
+ # profile may probe too (a first turn, a retry). Both run after
1501
+ # run_turn's probe-cancel swap, so a Stop cuts them, and a failure
1502
+ # ends the turn as turn_failed.
1503
+ @client.invalidate_context_window!
1484
1504
  refresh_profile!
1485
- # Provide a cancellable controller for this turn (cross-process cancel via file flag)
1486
- effective_controller = cancel_controller || CancellationController.new
1487
- @activity_mutex.synchronize do
1488
- @active_cancel_controller = effective_controller
1489
- # A hook's notice goes where the turn's events go (the REPL renders
1490
- # only its sink; a worker's observers carry it to the bridge).
1491
- @turn_event_sink = on_event
1505
+
1506
+ # Before anything of the turn is kept or a reminder is used up.
1507
+ turn.settings = turn_settings(session)
1508
+ refuse_images!(turn.settings.vision) unless image_refs.empty?
1509
+ announce_guardrail_failures(turn.on_event)
1510
+ # Plugins' slow setup that brings tools (an MCP server's first
1511
+ # start): the turn waits for it here, before the system prompt
1512
+ # declares the tools; a Ctrl-C ends the wait (the turn is cancelled
1513
+ # at its first request).
1514
+ start_init_tasks!
1515
+ await_init_tasks(turn.controller, turn.on_event)
1516
+ # Plugins' tool sets that changed since the last turn.
1517
+ apply_staged_tools!
1518
+
1519
+ # Fire :session_start on the very first turn
1520
+ if @first_turn
1521
+ @hooks.fire(:session_start, { type: :session_start, session_id: session.id })
1522
+ @first_turn = false
1492
1523
  end
1493
- # The gate's context: who queued this turn, and git asked afresh.
1494
- @guardrail_wiring.begin_turn(origin)
1495
1524
 
1496
- prompt = nil if continue
1497
- # For the cancel note: how long the turn ran.
1498
- turn_started_clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1499
- turn_seconds = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - turn_started_clock }
1500
- messages = nil
1501
- # Boundary events carry the origin only when there is one, so payloads
1502
- # stay unchanged for callers that don't pass it.
1503
- with_origin = origin ? ->(event) { event.merge(origin: origin) } : ->(event) { event }
1525
+ # Fire :before_turn hook, with a read-only copy of the history so far
1526
+ # and the prompt (nil on a continue).
1527
+ @hooks.fire(:before_turn, { type: :before_turn, session_id: session.id, prompt: turn.prompt,
1528
+ messages: hook_messages(session.messages) })
1529
+
1530
+ turn_messages(turn, image_refs)
1531
+ end
1532
+ private :prepare_turn
1533
+
1534
+ # The turn's settings for the kernel (LLM::TurnSettings): vision,
1535
+ # sampling and thinking, the thinking resolved before the hooks run.
1536
+ # The model name is added in #generate, after the hooks that may switch
1537
+ # the model.
1538
+ def turn_settings(session)
1539
+ vision = turn_vision(session)
1540
+ sampling = turn_sampling
1541
+ thinking_target = @host_registry.resolve(@effective_model_name)
1542
+ @turn_thinking = [thinking_level(thinking_target), thinking_target]
1543
+ announce_thinking_level(*@turn_thinking)
1544
+ LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil)
1545
+ end
1546
+ private :turn_settings
1547
+
1548
+ # The messages the turn sends: the history under the system head, due
1549
+ # reminders, the prompt. turn.messages is set first and grown in place,
1550
+ # so a Ctrl-C or a failure meanwhile keeps what is there.
1551
+ def turn_messages(turn, image_refs)
1552
+ session = turn.session
1553
+ turn.messages = session.messages.dup
1554
+ # Built once per Engine (and again after a model switch) so the prompt
1555
+ # prefix, and the model server's KV cache for it, stay stable.
1556
+ turn.messages = ContextNote.with_system_head(turn.messages, { role: "system", content: system_prompt })
1557
+ # Explicit --memory preloads are now known after system prompt build.
1558
+ @used_memories.add(activated_memory_names).absorb(session)
1559
+
1560
+ # Inject due reminders as a tail system message (after history, before
1561
+ # the new user prompt) to preserve prefix KV cache. Mutating the head
1562
+ # system prompt invalidates cache for the entire prefix.
1563
+ due_reminders = @reminder_queue.inject!(turn.messages)
1564
+ if due_reminders.any?
1565
+ emit_event(turn.on_event, {
1566
+ type: :reminder_injected,
1567
+ reminders: due_reminders
1568
+ })
1569
+ end
1570
+
1571
+ return if turn.continue
1572
+
1573
+ user_message = { role: "user", content: turn.prompt }
1574
+ user_message[:images] = image_refs unless image_refs.empty?
1575
+ turn.messages << user_message
1576
+ session.last_prompt = turn.prompt
1577
+ end
1578
+ private :turn_messages
1579
+
1580
+ # Ask the effective model's backend.
1581
+ def generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
1582
+ sync_kernel_client!
1583
+ # Route model name as bare (without host prefix) to the transport;
1584
+ # host selection already done via active client.
1585
+ bare_for_backend = bare_model_name(@effective_model_name)
1586
+ # The turn's settings, set once here. The chat loop dispatches tools
1587
+ # through the kernel without its #run: tag those dumps with this
1588
+ # turn's model, not the last native one.
1589
+ @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend)
1590
+
1591
+ backend.complete(
1592
+ messages: turn.messages,
1593
+ max_iterations: @no_interrupt ? NO_INTERRUPT_MAX_ITERATIONS : max_iterations,
1594
+ on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
1595
+ cancel_controller: turn.controller,
1596
+ model_name: bare_for_backend,
1597
+ max_tool_output_chars: max_tool_output_chars,
1598
+ pending_input: turn_drain(pending_input)
1599
+ )
1600
+ end
1601
+ private :generate
1602
+
1603
+ def publish_used_memories(session, on_event)
1604
+ # Persist deduped used memories onto the session for Web + reload.
1504
1605
  begin
1505
- # A Stop cuts the /props probes this thread makes for the turn
1506
- # (window, served model, vision) instead of waiting their timeout.
1507
- probe_cancel_before = Client.swap_probe_cancel(effective_controller)
1508
- image_refs, image_error = turn_image_refs(session, continue ? [] : images)
1509
- # Emit turn_started event
1510
- bind_metrics(session)
1511
- turn_started = { type: :turn_started, session_id: session.id, prompt: prompt }
1512
- turn_started[:continue] = true if continue
1513
- turn_started[:images] = image_refs unless image_refs.empty?
1514
- emit_event(on_event, with_origin.call(turn_started))
1515
- raise image_error if image_error
1516
-
1517
- # Before anything of the turn is kept or a reminder is used up.
1518
- vision = turn_vision(session)
1519
- @kernel.vision = vision if @kernel.respond_to?(:vision=)
1520
- @kernel.sampling = turn_sampling if @kernel.respond_to?(:sampling=)
1521
- thinking_target = @host_registry.resolve(@effective_model_name)
1522
- @turn_thinking = [thinking_level(thinking_target), thinking_target]
1523
- @kernel.thinking = @turn_thinking.first if @kernel.respond_to?(:thinking=)
1524
- announce_thinking_level(*@turn_thinking)
1525
- refuse_images!(vision) unless image_refs.empty?
1526
- announce_guardrail_failures(on_event)
1527
- # Plugins' slow setup that brings tools (an MCP server's first
1528
- # start): the turn waits for it here, before the system prompt
1529
- # declares the tools; a Ctrl-C ends the wait (the turn is cancelled
1530
- # at its first request).
1531
- start_init_tasks!
1532
- await_init_tasks(effective_controller, on_event)
1533
- # Plugins' tool sets that changed since the last turn.
1534
- apply_staged_tools!
1535
-
1536
- # Fire :session_start on the very first turn
1537
- if @first_turn
1538
- @hooks.fire(:session_start, { type: :session_start, session_id: session.id })
1539
- @first_turn = false
1540
- end
1606
+ session.used_memory_names = used_memory_names
1607
+ rescue StandardError
1608
+ nil
1609
+ end
1610
+ # Notify live observers of the updated memory list (so yellow bar refreshes
1611
+ # even without a tool_call event if the preload was the only addition).
1612
+ # Only emit when there is something to report to avoid noisy event_count drift.
1613
+ return unless used_memory_names.any?
1541
1614
 
1542
- # Fire :before_turn hook, with a read-only copy of the history so far
1543
- # and the prompt (nil on a continue).
1544
- @hooks.fire(:before_turn, { type: :before_turn, session_id: session.id, prompt: prompt,
1545
- messages: hook_messages(session.messages) })
1546
-
1547
- messages = session.messages.dup
1548
- # Built once per Engine (and again after a model switch) so the prompt
1549
- # prefix, and the model server's KV cache for it, stay stable.
1550
- messages = ContextNote.with_system_head(messages, { role: "system", content: system_prompt })
1551
- # Explicit --memory preloads are now known after system prompt build.
1552
- add_used_memory_names(activated_memory_names)
1553
- sync_used_memories_from_session(session)
1554
-
1555
- # Inject due reminders as a tail system message (after history, before
1556
- # the new user prompt) to preserve prefix KV cache. Mutating the head
1557
- # system prompt invalidates cache for the entire prefix.
1558
- due_reminders = collect_due_reminders(messages)
1559
- if due_reminders.any?
1560
- emit_event(on_event, {
1561
- type: :reminder_injected,
1562
- reminders: due_reminders
1563
- })
1615
+ begin
1616
+ emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
1617
+ rescue StandardError
1618
+ nil
1619
+ end
1620
+ end
1621
+ private :publish_used_memories
1622
+
1623
+ # The normal ending (an answer, an empty answer, a Stop, the iteration
1624
+ # limit), then the after-turn hooks.
1625
+ def complete_turn(turn, result)
1626
+ canceled = result.canceled?
1627
+ # after_turn hooks run below and may present the answer (the web
1628
+ # holds its pop until it knows).
1629
+ display_pending = !canceled && @hooks.any?(:after_turn)
1630
+ end_turn(turn, canceled ? "canceled" : "completed") do |seconds|
1631
+ kept = kept_messages(turn, result, seconds)
1632
+ if canceled
1633
+ [kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason,
1634
+ duration_ms: (seconds * 1000).round }]
1635
+ else
1636
+ # For a client that attaches later (session_state_snapshot).
1637
+ @last_context_status = result.context_status.dup if result.context_status
1638
+ [kept, { type: :turn_completed, result: result, turn_summary: turn_summary(result),
1639
+ display_pending: display_pending }]
1564
1640
  end
1641
+ end
1565
1642
 
1566
- unless continue
1567
- user_message = { role: "user", content: prompt }
1568
- user_message[:images] = image_refs unless image_refs.empty?
1569
- messages << user_message
1570
- session.last_prompt = prompt
1571
- end
1643
+ # Fire :after_turn hook (runs even on cancel/success), with a read-only
1644
+ # copy of the conversation the turn stored, and event[:present] for
1645
+ # a display version of the answer (AnswerDisplay).
1646
+ session = turn.session
1647
+ display = AnswerDisplay.new(session.messages)
1648
+ after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
1649
+ messages: hook_messages(session.messages) }
1650
+ after_turn[:present] = display.presenter(after_turn)
1651
+ @hooks.fire(:after_turn, after_turn)
1652
+ store_answer_display(session, display, pending: display_pending)
1653
+
1654
+ # Fire :session_end after every turn (turn-level lifecycle)
1655
+ @hooks.fire(:session_end, { type: :session_end, session_id: session.id })
1656
+ end
1657
+ private :complete_turn
1658
+
1659
+ # What the session keeps of a turn that ended normally (nil: nothing to
1660
+ # replace). Runs under the event lock: it also adds the turn's note to
1661
+ # result.conversation, which the REPL keeps as-is.
1662
+ # A turn that ran out of iterations ends at its tool results, so a
1663
+ # continue resumes from them rather than after a made-up reply.
1664
+ def kept_messages(turn, result, seconds)
1665
+ conversation = result.conversation if result.conversation.is_a?(Array)
1666
+ # Nothing visible (the native loop: no text; the chat loop: its
1667
+ # placeholder text) is a turn the model should know ended that way.
1668
+ if result.empty_answer?
1669
+ # The placeholder is for the UIs (a new array: it must not leak
1670
+ # into the result); the note is for the model, so it goes on the
1671
+ # result's conversation too.
1672
+ # A retry's nudge at the tail goes: this note says it all.
1673
+ note = TurnNote.empty
1674
+ conversation&.replace(TurnNote.without_trailing(conversation))
1675
+ saved = TurnNote.without_trailing(conversation || turn.session.messages)
1676
+ saved << { role: "model", content: "[No response]" } if result.output.to_s.strip.empty?
1677
+ conversation << note if conversation
1678
+ saved + [note]
1679
+ elsif result.canceled? && conversation
1680
+ conversation << TurnNote.cancelled(result.cancellation_reason, seconds: seconds,
1681
+ stopped_by: turn.controller.detail,
1682
+ shown: TurnNote.interrupted_tail?(conversation),
1683
+ running_tasks: Tools::TaskRuntime.running_created_in(conversation))
1684
+ else
1685
+ conversation
1686
+ end
1687
+ end
1688
+ private :kept_messages
1572
1689
 
1573
- sync_kernel_client!
1574
- # Route model name as bare (without host prefix) to the transport;
1575
- # host selection already done via active client.
1576
- bare_for_backend = bare_model_name(@effective_model_name)
1577
- # The chat loop dispatches tools through the kernel without its #run:
1578
- # tag those dumps with this turn's model, not the last native one.
1579
- @kernel.current_model_name = bare_for_backend if @kernel.respond_to?(:current_model_name=)
1580
-
1581
- result = backend.complete(
1582
- messages: messages,
1583
- max_iterations: max_iterations,
1584
- on_stream_event: build_stream_event_handler(on_event, cancel_controller: effective_controller),
1585
- cancel_controller: effective_controller,
1586
- model_name: bare_for_backend,
1587
- max_tool_output_chars: max_tool_output_chars,
1588
- pending_input: turn_drain(pending_input)
1589
- )
1590
-
1591
- # Persist deduped used memories onto the session for Web + reload.
1592
- begin
1593
- session.used_memory_names = used_memory_names
1594
- rescue StandardError
1595
- nil
1596
- end
1597
- # Notify live observers of the updated memory list (so yellow bar refreshes
1598
- # even without a tool_call event if the preload was the only addition).
1599
- # Only emit when there is something to report to avoid noisy event_count drift.
1600
- if used_memory_names.any?
1601
- begin
1602
- emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
1603
- rescue StandardError
1604
- nil
1605
- end
1606
- end
1690
+ # A Ctrl-C keeps the turn's messages so far with a cancel note (nil
1691
+ # before there are any). Only the pre-turn messages survive here, so
1692
+ # tasks this turn started are missed (plan wait-stop §6).
1693
+ def ctrl_c_messages(turn, seconds)
1694
+ return nil unless turn.messages
1607
1695
 
1608
- response = result.respond_to?(:output) ? result.output.to_s : result.to_s
1609
- canceled = result.respond_to?(:canceled?) && result.canceled?
1610
- conversation = result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
1611
- # Bring the turn into the session and announce its end as one step of
1612
- # the event log: a snapshot taken meanwhile (the Bridge's) shows the
1613
- # turn either in progress or in the messages, never both or neither.
1614
- # A turn that ran out of iterations ends at its tool results, so a
1615
- # continue resumes from them rather than after a made-up reply.
1616
- resumable = result.respond_to?(:resumable?) && result.resumable?
1617
- # Nothing visible (the native loop: no text; the chat loop: its
1618
- # placeholder text) is a turn the model should know ended that way.
1619
- empty = !canceled && !resumable &&
1620
- (response.strip.empty? || (result.respond_to?(:empty_answer?) && result.empty_answer?))
1621
- # after_turn hooks run below and may present the answer (the web
1622
- # holds its pop until it knows).
1623
- display_pending = !canceled && @hooks.any?(:after_turn)
1624
- synchronize_events do
1625
- if empty
1626
- # The placeholder is for the UIs (a new array: it must not leak
1627
- # into the result); the note is for the model, so it goes on the
1628
- # result's conversation too, which the REPL keeps as-is.
1629
- # A retry's nudge at the tail goes: this note says it all.
1630
- note = TurnNote.empty
1631
- conversation&.replace(TurnNote.without_trailing(conversation))
1632
- saved = TurnNote.without_trailing(conversation || session.messages)
1633
- saved << { role: "model", content: "[No response]" } if response.strip.empty?
1634
- replace_session_messages(session, saved + [note])
1635
- conversation << note if conversation
1636
- elsif canceled && conversation
1637
- conversation << TurnNote.cancelled(result.cancellation_reason, seconds: turn_seconds.call,
1638
- stopped_by: effective_controller.detail,
1639
- shown: TurnNote.interrupted_tail?(conversation),
1640
- running_tasks: Tools::TaskRuntime.running_created_in(conversation))
1641
- replace_session_messages(session, conversation)
1642
- elsif conversation
1643
- replace_session_messages(session, conversation)
1644
- end
1645
- session.status = Session::STATUS_IDLE
1646
- record_last_turn(session, canceled ? "canceled" : "completed", turn_seconds.call, origin)
1647
- if canceled
1648
- emit_event(on_event, with_origin.call({
1649
- type: :turn_canceled,
1650
- cancellation_reason: result.cancellation_reason,
1651
- duration_ms: (turn_seconds.call * 1000).round
1652
- }))
1653
- else
1654
- # For a client that attaches later (session_state_snapshot).
1655
- @last_context_status = result.context_status.dup if result.context_status
1656
- emit_event(on_event, with_origin.call({
1657
- type: :turn_completed,
1658
- result: result,
1659
- turn_summary: turn_summary(result),
1660
- display_pending: display_pending
1661
- }))
1662
- end
1663
- end
1664
- @metrics.persist(state_dir: session_state_dir)
1696
+ note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(turn.messages))
1697
+ TurnNote.replace_trailing(turn.messages, note)
1698
+ end
1699
+ private :ctrl_c_messages
1665
1700
 
1666
- # Fire :after_turn hook (runs even on cancel/success), with a read-only
1667
- # copy of the conversation the turn stored, and event[:present] for
1668
- # a display version of the answer (AnswerDisplay).
1669
- display = AnswerDisplay.new(session.messages)
1670
- after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
1671
- messages: hook_messages(session.messages) }
1672
- after_turn[:present] = display.presenter(after_turn)
1673
- @hooks.fire(:after_turn, after_turn)
1674
- store_answer_display(session, display, pending: display_pending)
1701
+ # A failure keeps the loop's partial conversation (else the turn's
1702
+ # messages so far) with a failed note; nil when the turn never reached
1703
+ # the model. The model reads why on its next turn (a UI that rolls the
1704
+ # turn back leaves its own note, TurnFlow#prompt_turn_failed).
1705
+ def failed_messages(turn, error)
1706
+ kept = error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array) ? error.partial_conversation : turn.messages
1707
+ return nil unless kept
1675
1708
 
1676
- # Fire :session_end after every turn (turn-level lifecycle)
1677
- @hooks.fire(:session_end, { type: :session_end, session_id: session.id })
1709
+ summary = error.respond_to?(:summary) ? error.summary : error.message
1710
+ TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: turn.continue))
1711
+ end
1712
+ private :failed_messages
1678
1713
 
1679
- result
1680
- rescue Interrupt
1681
- effective_controller.cancel!(:ctrl_c)
1682
- synchronize_events do
1683
- # Only the pre-turn messages survive here, so tasks this turn
1684
- # started are missed (plan wait-stop §6).
1685
- if messages
1686
- note = TurnNote.cancelled(:ctrl_c, seconds: turn_seconds.call,
1687
- running_tasks: Tools::TaskRuntime.running_created_in(messages))
1688
- replace_session_messages(session, TurnNote.replace_trailing(messages, note))
1689
- end
1690
- session.status = Session::STATUS_IDLE
1691
- record_last_turn(session, "canceled", turn_seconds.call, origin)
1692
- emit_event(on_event, with_origin.call({ type: :turn_canceled, cancellation_reason: :ctrl_c,
1693
- duration_ms: (turn_seconds.call * 1000).round }))
1694
- end
1695
- @metrics.persist(state_dir: session_state_dir)
1696
- raise
1697
- rescue StandardError => e
1698
- # Keep what the turn got to (the prompt plus the loop's completed
1699
- # tool iterations) like a cancel does, and save it: a worker exits
1700
- # after a failed turn. The REPL still rolls back to its checkpoint.
1701
- kept = e.respond_to?(:partial_conversation) && e.partial_conversation.is_a?(Array) ? e.partial_conversation : messages
1702
- # The model reads why on its next turn (a UI that rolls the turn back
1703
- # leaves its own note, TurnFlow#prompt_turn_failed). Nothing when the
1704
- # turn never reached the model (kept is nil).
1705
- if kept
1706
- summary = e.respond_to?(:summary) ? e.summary : e.message
1707
- kept = TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: continue))
1708
- end
1709
- replace_session_messages(session, kept) if kept
1710
- session.status = Session::STATUS_IDLE
1711
- record_last_turn(session, "failed", turn_seconds.call, origin)
1712
- begin; session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1713
- failed = { type: :turn_failed, error_class: e.class.name, message: e.message,
1714
- duration_ms: (turn_seconds.call * 1000).round }
1715
- # A provider error says what kind it is, for one line per kind in the UIs.
1716
- if e.is_a?(LLM::ProviderError)
1717
- failed.merge!(error_kind: e.kind, retryable: e.retryable?, host: e.host, summary: e.summary)
1718
- end
1719
- emit_event(on_event, with_origin.call(failed))
1720
- @metrics.persist(state_dir: session_state_dir)
1721
- raise
1722
- ensure
1723
- # A completed turn is activity: release the turn flag and advance the
1724
- # shared inactivity clock so the idle recap detector (shared with the REPL)
1725
- # treats the just-finished turn as activity and re-arms its window.
1726
- # Always runs, even if an exception occurred.
1727
- set_turn_running(false)
1728
- Client.swap_probe_cancel(probe_cancel_before)
1729
- left = @activity_mutex.synchronize do
1730
- @active_cancel_controller = nil
1731
- @turn_event_sink = nil
1732
- @steers.tap { @steers = [] }
1733
- end
1734
- log_dropped_steers(left, "turn_ended")
1735
- record_activity
1736
- # Clear hooks so they remain turn-scoped and never leak into the next turn.
1737
- clear_hooks
1714
+ def failed_event(error, seconds)
1715
+ failed = { type: :turn_failed, error_class: error.class.name, message: error.message,
1716
+ duration_ms: (seconds * 1000).round }
1717
+ # A provider error says what kind it is, for one line per kind in the UIs.
1718
+ if error.is_a?(LLM::ProviderError)
1719
+ failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: error.summary)
1738
1720
  end
1739
- end
1721
+ failed
1722
+ end
1723
+ private :failed_event
1724
+
1725
+ # However the turn ended (#run_turn's ensure): the turn flag off, the
1726
+ # probe cancel restored, the cancel controller, sink and steers cleared
1727
+ # (a steer left is logged as dropped), activity recorded, the
1728
+ # turn-scoped hooks cleared.
1729
+ def release_turn(probe_cancel_before)
1730
+ # A completed turn is activity: release the turn flag and advance the
1731
+ # shared inactivity clock so the idle recap detector (shared with the REPL)
1732
+ # treats the just-finished turn as activity and re-arms its window.
1733
+ left = @turn_state.finish!
1734
+ Client.swap_probe_cancel(probe_cancel_before)
1735
+ log_dropped_steers(left, "turn_ended")
1736
+ record_activity
1737
+ # Clear hooks so they remain turn-scoped and never leak into the next turn.
1738
+ clear_hooks
1739
+ end
1740
+ private :release_turn
1740
1741
 
1741
1742
  # [refs, nil] for a turn's images, or [[], error] when one can't be used
1742
1743
  # (the turn then fails right after :turn_started).
@@ -1795,7 +1796,7 @@ module Samagotchi
1795
1796
  return unless target&.entry&.chat? && Thinking::EFFORTS.include?(level)
1796
1797
 
1797
1798
  client = target.client
1798
- props = client.respond_to?(:cached_server_props) ? client.cached_server_props(model: target.bare_model) : nil
1799
+ props = client.cached_server_props(model: target.bare_model)
1799
1800
  return unless Thinking.effort_ignored?(level, props)
1800
1801
 
1801
1802
  thinking_notice_once(:unsupported, target, :info,
@@ -1920,8 +1921,16 @@ module Samagotchi
1920
1921
 
1921
1922
  private
1922
1923
 
1924
+ # The one named seam for replacing a session's conversation (the
1925
+ # array is replaced, never mutated in place). No lock: a reader gets
1926
+ # the old array or the new one, and both are whole.
1927
+ # The turn as other threads see it (TurnState).
1928
+ attr_reader :turn_state
1929
+ # The due reminders on their way into a turn (ReminderQueue).
1930
+ attr_reader :reminder_queue
1931
+
1923
1932
  def replace_session_messages(session, messages)
1924
- @activity_mutex.synchronize { session.messages = messages }
1933
+ session.messages = messages
1925
1934
  end
1926
1935
 
1927
1936
  # Pick the loop for a target; the chat loop is pointed at the target
@@ -2037,11 +2046,9 @@ module Samagotchi
2037
2046
  ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:|
2038
2047
  hook_ask_user(question, options, header, allow_freeform, hook)
2039
2048
  },
2040
- # Nothing to cancel while the Engine is still being built (a
2041
- # server that starts as its plugin loads).
2042
2049
  # An init task's is its own (a Ctrl-C ends a turn, not the task).
2043
2050
  cancelled: lambda {
2044
- (task = current_init_task) ? task.cancelled? : @activity_mutex && active_cancel_controller&.cancelled?
2051
+ (task = current_init_task) ? task.cancelled? : active_cancel_controller&.cancelled?
2045
2052
  },
2046
2053
  card: ->(**card) { show_card(**card) },
2047
2054
  steer: ->(text, label) { steer(text, source: hook_source(label)) },
@@ -2115,7 +2122,7 @@ module Samagotchi
2115
2122
  @auto_turn_callback = auto_turn_callback
2116
2123
  IdleReminders.new(
2117
2124
  engine: self,
2118
- reminder_store: @reminder_store,
2125
+ queue: @reminder_queue,
2119
2126
  callback: @auto_turn_callback
2120
2127
  )
2121
2128
  end
@@ -2177,7 +2184,7 @@ module Samagotchi
2177
2184
  def emit_event(on_event, event)
2178
2185
  # Capture used memories synchronously in the turn thread.
2179
2186
  read_names = begin
2180
- capture_used_memory_from_event(event)
2187
+ @used_memories.capture(event, muted: @muted_memory_names)
2181
2188
  rescue StandardError
2182
2189
  nil
2183
2190
  end
@@ -2209,7 +2216,7 @@ module Samagotchi
2209
2216
  adapter = nil
2210
2217
  if target.entry.chat?
2211
2218
  adapter = @host_registry.adapter_for(target.entry)
2212
- client = nil if adapter.respond_to?(:remote?) && adapter.remote?
2219
+ client = nil if adapter.remote?
2213
2220
  end
2214
2221
  ContextWindow.resolve(client: client, model: target.bare_model, adapter: adapter)
2215
2222
  rescue StandardError
@@ -2227,9 +2234,9 @@ module Samagotchi
2227
2234
  client = target.client
2228
2235
  if target.entry.chat?
2229
2236
  adapter = @host_registry.adapter_for(target.entry)
2230
- return [nil, nil] if adapter.respond_to?(:remote?) && adapter.remote?
2237
+ return [nil, nil] if adapter.remote?
2231
2238
  end
2232
- served = ServedModel.from_props(client.server_props(model: target.bare_model)) if client.respond_to?(:server_props)
2239
+ served = ServedModel.from_props(client.server_props(model: target.bare_model))
2233
2240
  served ? [served, asked] : [nil, nil]
2234
2241
  rescue StandardError
2235
2242
  [nil, nil]
@@ -2256,7 +2263,7 @@ module Samagotchi
2256
2263
  # prompt format and parser, and the system prompts built for the old one.
2257
2264
  def apply_profile(resolution)
2258
2265
  @prompt_builder&.reset! if @profile_resolution && @profile_resolution.profile.name != resolution.profile.name
2259
- @kernel.use_profile!(resolution) if @kernel.respond_to?(:use_profile!)
2266
+ @kernel.use_profile!(resolution)
2260
2267
  resolution
2261
2268
  end
2262
2269