samagotchi 0.11.0 → 0.13.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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +90 -1
  3. data/README.md +20 -7
  4. data/bin/chi +10 -4
  5. data/docs/architecture.md +8 -6
  6. data/docs/cli.md +59 -11
  7. data/docs/configuration.md +29 -8
  8. data/docs/desktop.md +34 -8
  9. data/docs/guardrails.md +73 -7
  10. data/docs/hooks.md +13 -9
  11. data/docs/memory.md +5 -3
  12. data/docs/plugins.md +4 -4
  13. data/docs/sessions.md +9 -6
  14. data/docs/sub-agent.md +128 -0
  15. data/lib/samagotchi/answer_command.rb +220 -0
  16. data/lib/samagotchi/bridge.rb +15 -5
  17. data/lib/samagotchi/bridge_client.rb +8 -3
  18. data/lib/samagotchi/bundle_command.rb +18 -10
  19. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +19 -0
  20. data/lib/samagotchi/bundles/guardrails/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/config_modification_protocol.md +5 -5
  22. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  23. data/lib/samagotchi/cli/command.rb +3 -2
  24. data/lib/samagotchi/cli/exit.rb +23 -0
  25. data/lib/samagotchi/cli/parent_wait.rb +82 -0
  26. data/lib/samagotchi/config.rb +50 -18
  27. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  28. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  29. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  30. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  31. data/lib/samagotchi/desktop_command.rb +5 -3
  32. data/lib/samagotchi/engine.rb +480 -491
  33. data/lib/samagotchi/guardrails/parent_approvals.rb +155 -0
  34. data/lib/samagotchi/hooks/bundle_loader.rb +10 -3
  35. data/lib/samagotchi/host_registry.rb +26 -3
  36. data/lib/samagotchi/idle_reminders.rb +13 -36
  37. data/lib/samagotchi/kernel_loop.rb +82 -120
  38. data/lib/samagotchi/llm/chat_loop.rb +17 -38
  39. data/lib/samagotchi/llm/model_result.rb +14 -20
  40. data/lib/samagotchi/llm/native_backend.rb +3 -21
  41. data/lib/samagotchi/llm/openai_chat.rb +10 -2
  42. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  43. data/lib/samagotchi/llm/usage.rb +2 -57
  44. data/lib/samagotchi/memory_bundle/installer.rb +13 -1
  45. data/lib/samagotchi/memory_bundle/status.rb +9 -1
  46. data/lib/samagotchi/memory_paths.rb +8 -0
  47. data/lib/samagotchi/model_catalog.rb +101 -0
  48. data/lib/samagotchi/model_profile.rb +31 -17
  49. data/lib/samagotchi/models_command.rb +112 -0
  50. data/lib/samagotchi/parent_report.rb +213 -0
  51. data/lib/samagotchi/plugin/api.rb +15 -2
  52. data/lib/samagotchi/prompt_literal_guard.rb +4 -2
  53. data/lib/samagotchi/question_desk.rb +29 -2
  54. data/lib/samagotchi/recap_setup.rb +15 -5
  55. data/lib/samagotchi/reminder_queue.rb +73 -0
  56. data/lib/samagotchi/reminder_store.rb +3 -3
  57. data/lib/samagotchi/reply_wait.rb +5 -1
  58. data/lib/samagotchi/self_report.rb +8 -1
  59. data/lib/samagotchi/send_command.rb +51 -76
  60. data/lib/samagotchi/session.rb +15 -0
  61. data/lib/samagotchi/session_commands.rb +5 -5
  62. data/lib/samagotchi/session_manager.rb +36 -13
  63. data/lib/samagotchi/sessions_command.rb +13 -3
  64. data/lib/samagotchi/system_prompt.rb +10 -2
  65. data/lib/samagotchi/terminal_ui/attach_launcher.rb +33 -5
  66. data/lib/samagotchi/terminal_ui/attached_loop.rb +17 -5
  67. data/lib/samagotchi/terminal_ui.rb +23 -29
  68. data/lib/samagotchi/thinking.rb +1 -1
  69. data/lib/samagotchi/thought_stream_splitter.rb +4 -4
  70. data/lib/samagotchi/tool_call_parser.rb +5 -6
  71. data/lib/samagotchi/tool_runner.rb +4 -6
  72. data/lib/samagotchi/tools/builtin_calls.rb +14 -3
  73. data/lib/samagotchi/tools/builtins.rb +15 -3
  74. data/lib/samagotchi/tools/delegate_wait.rb +17 -5
  75. data/lib/samagotchi/tools/execute.rb +8 -8
  76. data/lib/samagotchi/tools/task_create.rb +3 -2
  77. data/lib/samagotchi/tools/task_runtime.rb +7 -2
  78. data/lib/samagotchi/turn_flow.rb +4 -4
  79. data/lib/samagotchi/turn_state.rb +126 -0
  80. data/lib/samagotchi/used_memories.rb +88 -0
  81. data/lib/samagotchi/version.rb +1 -1
  82. data/lib/samagotchi/vision_support.rb +4 -4
  83. data/lib/samagotchi/web/app.rb +17 -35
  84. data/lib/samagotchi/web/message_parts.rb +2 -1
  85. data/lib/samagotchi/web/server.rb +2 -1
  86. data/lib/samagotchi/web/session_hub.rb +0 -4
  87. data/lib/samagotchi/web/session_summary.rb +8 -16
  88. data/lib/samagotchi/worker.rb +1 -1
  89. metadata +14 -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"
@@ -95,6 +100,10 @@ module Samagotchi
95
100
  # delegate (a child would outlive it) nor plugin fork
96
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,
97
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 })
98
107
  @scratch = scratch
99
108
  @no_interrupt = no_interrupt
100
109
  @chat_backend = nil
@@ -189,47 +198,39 @@ module Samagotchi
189
198
  @shut_down = false
190
199
  load_plugins if plugins
191
200
  guardrail_rules
192
- # Use the KernelLoop's reminder_store if provided (TerminalUI path),
193
- # otherwise create our own (SessionManager/one-shot paths). This ensures
194
- # tool calls via KernelLoop and reminder injection via Engine read/write
195
- # the same store.
196
- if kernel && kernel.respond_to?(:reminder_store)
197
- @reminder_store = kernel.reminder_store
198
- else
199
- @reminder_store = ReminderStore.new
200
- 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)
201
208
  # Build the idle reminders detector (wired to reminder_store)
202
209
  callback = reminders.is_a?(Hash) && reminders[:callback] ? reminders[:callback] : nil
203
210
  @reminders = build_reminders(auto_turn_callback: callback)
204
211
  # Track whether this is the first turn in the session (for session_start event)
205
212
  @first_turn = true
213
+ # A given kernel (specs) gets the Engine's store, hooks and tools, as
214
+ # the one built here does.
206
215
  @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, hooks: @hooks, reminder_store: @reminder_store,
207
216
  tools: @tools)
217
+ @kernel.reminder_store = @reminder_store
218
+ @kernel.hooks = @hooks
219
+ @kernel.tools = @tools
208
220
  sync_kernel_client!
209
221
  sync_model_key!
210
222
  # The mutes never change during a session, so no re-sync: the kernel's
211
223
  # memory_read guard reads the same list for every turn.
212
224
  @muted_memory_names = MutedMemories.normalize_list(muted_memories)
213
- @kernel.muted_memory_names = @muted_memory_names if @kernel.respond_to?(:muted_memory_names=)
214
- # Keep kernel client in sync with active host via setter
215
- @kernel_client_synced = false
216
- # Engine owns hooks; if a kernel was supplied externally (TUI path) propagate
217
- # the Engine's registry so all UIs reuse the same instance. Without this the
218
- # TUI's KernelLoop fires with nil hooks and before_generation/after_generation
219
- # etc. never fire in interactive mode.
220
- if kernel && @kernel.respond_to?(:hooks=)
221
- @kernel.hooks = @hooks
222
- end
223
- # Likewise its tools: the REPL builds its kernel before the Engine.
224
- @kernel.tools = @tools if kernel && @kernel.respond_to?(:tools=)
225
+ @kernel.muted_memory_names = @muted_memory_names
225
226
  # ask_user_question blocks on the Engine's question flow (TUI/Web answer it).
226
- @kernel.question_handler = proc { |payload| request_question(payload) } if @kernel.respond_to?(:question_handler=)
227
+ @kernel.question_handler = proc { |payload| request_question(payload) }
227
228
  # Every tool call asks this gate first. The kernel is never rebuilt, so
228
229
  # it holds across model switches.
229
230
  self.guardrail_state_dir = Session.default_state_dir
230
231
  # list_sessions and send_note speak for whichever session runs now.
231
- @kernel.peers = PeerView.new(self) if @kernel.respond_to?(:peers=)
232
- @kernel.guardrail_gate = @guardrail_wiring.gate if @kernel.respond_to?(:guardrail_gate=)
232
+ @kernel.peers = PeerView.new(self)
233
+ @kernel.guardrail_gate = @guardrail_wiring.gate
233
234
  # What a hook can do beyond reading its event (event[:notify],
234
235
  # event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
235
236
  @hooks.runtime = hook_runtime
@@ -244,29 +245,18 @@ module Samagotchi
244
245
  @session = nil
245
246
  @session_observer = SessionObserver.new
246
247
  @metrics = SessionMetrics.new
247
- @used_memory_names = []
248
- @used_memory_mutex = Monitor.new
248
+ @used_memories = UsedMemories.new
249
249
  # Hydrate from resumed session if present
250
- if @resume_session && @resume_session.respond_to?(:used_memory_names)
251
- @used_memory_names = Array(@resume_session.used_memory_names).map(&:to_s).reject(&:empty?).uniq
250
+ if @resume_session
251
+ @used_memories.absorb(@resume_session)
252
252
  @session = @resume_session
253
253
  end
254
- # Shared inactivity clock + turn-running flag for the idle subsystems
255
- # (session recap + reminders), all polled by the shared IdleScheduler.
256
- # `record_activity` is the single seam every UI calls (run_turn itself,
257
- # the REPL on keystrokes and after a reminder turn), so the idle
258
- # layer's clock is identical across UIs.
259
- @activity_mutex = Monitor.new
260
- @last_activity_at = monotonic_now
261
- @activity_seq = 0
262
- @turn_running = false
263
- @active_cancel_controller = nil
264
- # Plugin steers ({text:, source:}) waiting for the running turn's next
265
- # boundary (#steer); guarded by @activity_mutex.
266
- @steers = []
267
- # Reminder names the interactive REPL's IdleReminders callback marked due;
268
- # the REPL polls them to decide when to run a synthetic reminder turn.
269
- @due_reminder_names = []
254
+ # The idle subsystems' (session recap + reminders) inactivity clock
255
+ # starts now, after the plugins loaded. `record_activity` is the
256
+ # single seam every UI calls (run_turn itself, the REPL on keystrokes
257
+ # and after a reminder turn), so the idle layer's clock is identical
258
+ # across UIs.
259
+ @turn_state.restart_clock!
270
260
  @recap = RecapSetup.build(recap, engine: self, host_registry: @host_registry,
271
261
  session_target: -> { session_model_recap_target },
272
262
  session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
@@ -296,37 +286,29 @@ module Samagotchi
296
286
  backend_for(@host_registry.resolve(@effective_model_name))
297
287
  end
298
288
 
299
- # Record that activity happened (user input or a completed turn). Shared,
300
- # mutex-guarded seam for the idle recap detector. Idempotent-ish: each call
301
- # advances both the last-activity timestamp and the activity sequence.
289
+ # Record that activity happened (user input or a completed turn): the
290
+ # idle jobs' seam (TurnState#record_activity). Each call advances both
291
+ # the last-activity timestamp and the activity sequence.
302
292
  # @param now [Float, nil] injectable monotonic time (defaults to now)
303
293
  def record_activity(now = nil)
304
- @activity_mutex.synchronize do
305
- @last_activity_at = now ? now.to_f : monotonic_now
306
- @activity_seq += 1
307
- end
294
+ @turn_state.record_activity(now)
308
295
  end
309
296
 
310
297
  # @return [Float] monotonic seconds of the last recorded activity
311
298
  def last_activity_at
312
- @activity_mutex.synchronize { @last_activity_at }
299
+ @turn_state.last_activity_at
313
300
  end
314
301
 
315
302
  # @return [Integer] monotonically-increasing activity counter (advanced by
316
303
  # #record_activity; lets the idle detector summarize once per idle window)
317
304
  def activity_seq
318
- @activity_mutex.synchronize { @activity_seq }
305
+ @turn_state.activity_seq
319
306
  end
320
307
 
321
- # Mark whether a turn is currently running (shared with the idle detector so
322
- # a recap never fires, or renders, while the model is generating).
323
- def set_turn_running(running)
324
- @activity_mutex.synchronize { @turn_running = running }
325
- end
326
-
327
- # @return [Boolean] true while a turn is in flight
308
+ # @return [Boolean] true while a turn is in flight (the idle jobs never
309
+ # fire, nor render, while the model is generating)
328
310
  def turn_running?
329
- @activity_mutex.synchronize { @turn_running }
311
+ @turn_state.running?
330
312
  end
331
313
 
332
314
  # Put +text+ into the running turn, as a UI's steering does: it joins the
@@ -337,15 +319,7 @@ module Samagotchi
337
319
  # the turn ends first, it is dropped (logged).
338
320
  # @return [Boolean] whether it was queued
339
321
  def steer(text, source:)
340
- text = text.to_s.strip
341
- return false if text.empty?
342
-
343
- @activity_mutex.synchronize do
344
- return false unless @turn_running
345
-
346
- @steers << { text: text, source: source.to_s }
347
- end
348
- true
322
+ @turn_state.steer(text, source: source)
349
323
  end
350
324
 
351
325
  # The drain a turn's loop gets: the caller's lines (a UI's steering; nil
@@ -362,11 +336,7 @@ module Samagotchi
362
336
  private :turn_drain
363
337
 
364
338
  def take_steers(drop)
365
- steers = @activity_mutex.synchronize do
366
- taken = @steers
367
- @steers = []
368
- taken
369
- end
339
+ steers = @turn_state.take_steers
370
340
  return steers unless drop
371
341
 
372
342
  log_dropped_steers(steers, "answered")
@@ -383,41 +353,37 @@ module Samagotchi
383
353
 
384
354
  # @return [Array<String>] reminder names queued for a synthetic REPL turn
385
355
  def due_reminder_names
386
- @activity_mutex.synchronize { @due_reminder_names.dup }
356
+ @reminder_queue.pending_names
387
357
  end
388
358
 
389
359
  # Queue reminder names for a synthetic REPL turn (IdleReminders callback).
390
360
  def note_due_reminders(names)
391
- @activity_mutex.synchronize { @due_reminder_names = Array(names).dup }
361
+ @reminder_queue.note_pending(names)
392
362
  end
393
363
 
394
364
  def clear_due_reminder_names!
395
- @activity_mutex.synchronize { @due_reminder_names = [] }
365
+ @reminder_queue.clear_pending!
396
366
  end
397
367
 
398
368
  # @return [CancellationController, nil] active turn's cancellation controller
399
369
  def active_cancel_controller
400
- @activity_mutex.synchronize { @active_cancel_controller }
370
+ @turn_state.controller
401
371
  end
402
372
 
403
373
  # Cancel the currently running turn, if any.
404
374
  # @param reason [Symbol] cancellation reason
405
375
  # @return [Boolean] whether a cancellation was triggered
406
376
  def cancel_current_turn!(reason = :manual)
407
- ctrl = active_cancel_controller
408
- return false unless ctrl
409
-
410
- ctrl.cancel!(reason)
377
+ @turn_state.cancel!(reason)
411
378
  end
412
379
 
413
380
  # Snapshot the current session messages as a JSON string for the idle
414
- # recap. Reads the array reference under the mutex (a single atomic
415
- # pointer read in CRuby) then serializes a dup'd copy OUTSIDE the lock so
416
- # the brief serialization never blocks the main turn thread. Never mutates
417
- # session.messages.
381
+ # recap. The array is never mutated in place (a turn or a note replaces
382
+ # it), so the reference read here is a consistent snapshot; a dup'd
383
+ # copy is serialized. Never mutates session.messages.
418
384
  # @return [String] JSON array of the messages
419
385
  def messages_json_for_recap
420
- snapshot = @activity_mutex.synchronize { @session&.messages }
386
+ snapshot = @session&.messages
421
387
  return "[]" if snapshot.nil?
422
388
 
423
389
  JSON.generate(AnswerDisplay.strip_all(snapshot).map(&:dup))
@@ -453,7 +419,7 @@ module Samagotchi
453
419
  def store_model!(session)
454
420
  ref = effective_model_ref
455
421
  session.model_name = ref
456
- session.model_typed = @typed_model_name.to_s == ref ? nil : @typed_model_name if session.respond_to?(:model_typed=)
422
+ session.model_typed = @typed_model_name.to_s == ref ? nil : @typed_model_name
457
423
  session
458
424
  end
459
425
 
@@ -495,7 +461,7 @@ module Samagotchi
495
461
  @model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
496
462
  typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(@typed_model_name).last)
497
463
  fallback = typed_key == @model_key ? nil : typed_key
498
- @kernel.sync_model_key!(@model_key, fallback: fallback) if @kernel.respond_to?(:sync_model_key!)
464
+ @kernel.sync_model_key!(@model_key, fallback: fallback)
499
465
  end
500
466
  private :sync_model_key!
501
467
 
@@ -511,7 +477,7 @@ module Samagotchi
511
477
  def sync_kernel_client!
512
478
  target = @host_registry.resolve(@effective_model_name)
513
479
  @client = target.client
514
- @kernel.client = target.client if @kernel.respond_to?(:client=) && @kernel.client != target.client
480
+ @kernel.client = target.client if @kernel.client != target.client
515
481
  backend_for(target)
516
482
  end
517
483
 
@@ -609,11 +575,7 @@ module Samagotchi
609
575
  # A plugin's init task (chi.init): never a running turn's event.
610
576
  return announce(card.merge(in_turn: false)) && card[:id] if current_init_task
611
577
 
612
- sink = nil
613
- in_turn = @activity_mutex.synchronize do
614
- sink = @turn_event_sink
615
- @turn_running
616
- end
578
+ in_turn, sink = @turn_state.in_turn_sink
617
579
  card[:in_turn] = in_turn ? true : false
618
580
  if in_turn
619
581
  emit_event(sink, card)
@@ -819,7 +781,7 @@ module Samagotchi
819
781
  sync_model_key!
820
782
  @prompt_builder.reset!
821
783
  sync_kernel_client!
822
- @client.invalidate_context_window! if @client.respond_to?(:invalidate_context_window!)
784
+ @client.invalidate_context_window!
823
785
  @metrics.forget_model_reports!
824
786
  ref = effective_model_ref
825
787
  if persist_default
@@ -858,53 +820,17 @@ module Samagotchi
858
820
  end
859
821
 
860
822
  # ── Reminders API ────────────────────────────────────────────────────────────
861
-
862
- # Get and inject all due reminders into the conversation. Called at the top
863
- # of run_turn (before set_turn_running(true)) so injection happens on the
864
- # main thread, serially with the turn — no TOCTOU race.
865
- #
866
- # Returns the array of due reminder hashes (may be empty). Injects a
867
- # [SYSTEM: REMINDERS DUE] message into the conversation when there are
868
- # Get due reminders, inject them into the provided messages array as
869
- # [SYSTEM: REMINDERS DUE], and atomically mark all as fired under one
870
- # ReminderStore lock.
871
- #
872
- # This is the canonical method for reminder injection, called from
873
- # Engine#run_turn after system prompt construction so the reminder text
874
- # is never overwritten.
875
823
  #
876
- # @param messages [Array<Hash>] the conversation messages (mutated in place)
877
- # @return [Array<Hash>] [{name:, description:, interval_minutes:}, ...]
878
- def collect_due_reminders(messages)
879
- due = @reminders&.due_reminders
880
- return [] if due.nil? || due.empty?
881
-
882
- reminder_lines = due.map do |r|
883
- " #{r[:name]}: #{r[:description]} (interval: #{r[:interval_minutes]}m)"
884
- end.join("\n")
885
- reminder_text = "[SYSTEM: REMINDERS DUE]\n#{reminder_lines}\n[END REMINDERS]"
886
- # Append as a tail message to preserve prefix KV cache. Mutating the
887
- # head system prompt invalidates the cache for the entire conversation
888
- # (prompt re-evaluated every interval). A tail append keeps the prefix
889
- # intact — only the new reminder suffix is evaluated. Mirrors the
890
- # context guidance line KernelLoop#run appends (ContextStatus#take_guidance).
891
- messages << { role: "system", content: reminder_text }
892
- # Atomically mark all due as fired under one lock and clear the
893
- # IdleReminders latch so the next interval can be detected.
894
- @reminder_store&.mark_fired_batch(due.map { |r| r[:name] })
895
- @reminders&.clear_due
896
- # Also clear the REPL's due-reminder queue (#note_due_reminders).
897
- # Without this, a normal-turn injection leaves a stale entry, causing
898
- # the next top-of-loop synthetic turn to fire empty and duplicate output.
899
- clear_due_reminder_names!
900
- due
901
- end
824
+ # Each turn injects the reminders due then (#turn_messages,
825
+ # ReminderQueue#inject!). Between turns the idle tick (IdleReminders)
826
+ # finds them due and its callback queues their names
827
+ # (#note_due_reminders); the worker and the REPL run a reminder turn
828
+ # (a continue turn) for them.
902
829
 
903
830
  # @return [Boolean] whether any reminder is due now (the store's view,
904
- # which #collect_due_reminders would inject), regardless of the REPL queue
831
+ # which a turn would inject), regardless of the queued names
905
832
  def reminders_due?
906
- due = @reminders&.due_reminders
907
- !(due.nil? || due.empty?)
833
+ @reminder_queue.due?
908
834
  end
909
835
 
910
836
  # @return [ReminderStore] the reminder store for inspection
@@ -941,9 +867,9 @@ module Samagotchi
941
867
  # no turn yet (it woke after an idle exit): nil when either count is
942
868
  # unknown or context.status is off.
943
869
  def saved_context_status(context)
944
- return nil unless context && @kernel.respond_to?(:context_display)
870
+ return nil unless context
945
871
 
946
- @kernel.context_display(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
872
+ ContextStatus.new.display_for(used_tokens: context[:used_tokens], window_tokens: context[:window_tokens])
947
873
  end
948
874
 
949
875
  # The effective model is on a chat host (api: openai), whose loop uses
@@ -1008,7 +934,7 @@ module Samagotchi
1008
934
  event_seq: @session_observer&.event_count,
1009
935
  metrics: metrics,
1010
936
  pending_question: @question_desk.pending,
1011
- used_memory_names: @used_memory_mutex.synchronize { @used_memory_names.dup },
937
+ used_memory_names: @used_memories.names,
1012
938
  preloaded_memory_names: preloaded_memory_names,
1013
939
  muted_memory_names: @muted_memory_names.dup,
1014
940
  parent_id: @session&.parent_id,
@@ -1024,7 +950,7 @@ module Samagotchi
1024
950
 
1025
951
  # @return [Array<String>] deduped used memory names (thread-safe copy)
1026
952
  def used_memory_names
1027
- @used_memory_mutex.synchronize { @used_memory_names.dup }
953
+ @used_memories.names
1028
954
  end
1029
955
 
1030
956
  # @return [Array<String>] the memories hidden from this session (normalized names)
@@ -1039,77 +965,6 @@ module Samagotchi
1039
965
  @prompt_builder.preloaded_memory_names
1040
966
  end
1041
967
 
1042
- def memory_muted?(name)
1043
- MutedMemories.muted?(name, @muted_memory_names)
1044
- end
1045
-
1046
- def add_used_memory_names(names)
1047
- return if names.nil? || Array(names).empty?
1048
-
1049
- @used_memory_mutex.synchronize do
1050
- Array(names).each do |n|
1051
- v = n.to_s.strip
1052
- next if v.empty?
1053
- next if @used_memory_names.include?(v)
1054
-
1055
- @used_memory_names << v
1056
- end
1057
- end
1058
- end
1059
-
1060
- def sync_used_memories_from_session(session)
1061
- return unless session && session.respond_to?(:used_memory_names)
1062
-
1063
- add_used_memory_names(Array(session.used_memory_names))
1064
- end
1065
-
1066
- def memory_name_from_tool_call(call)
1067
- return nil unless call.is_a?(Hash)
1068
-
1069
- tool = call[:name].to_s
1070
- case tool
1071
- when Tools::MemoryRead::NAME
1072
- content = call[:content].to_s.strip
1073
- return nil if content.empty?
1074
-
1075
- # comma-separated names
1076
- content.split(",").map { |s| normalize_memory_name(s) }.compact
1077
- when Tools::Read::NAME
1078
- path = call[:content].to_s.strip.tr("\\", "/")
1079
- return nil if path.empty?
1080
- return nil unless path.match?(/memories\/.+\.md\z/)
1081
-
1082
- normalize_memory_name(path)
1083
- else
1084
- nil
1085
- end
1086
- end
1087
-
1088
- def normalize_memory_name(raw)
1089
- v = raw.to_s.strip
1090
- return nil if v.empty?
1091
-
1092
- # basename without .md, handle comma already split
1093
- base = File.basename(v, ".md").strip
1094
- base.empty? ? nil : base
1095
- end
1096
-
1097
- # A memory read (memory_read, or read of a memories/*.md file) as it
1098
- # starts: its names join used_memory_names.
1099
- # @return [Array<String>, nil] the names this call read, nil for any other event
1100
- def capture_used_memory_from_event(event)
1101
- return unless event.is_a?(Hash) && event[:type] == :tool_call_started
1102
-
1103
- call = event[:call].is_a?(Hash) ? event[:call] : {}
1104
- names = memory_name_from_tool_call(call)
1105
- # A refused read of a muted memory is not a use of it.
1106
- names = Array(names).reject { |n| memory_muted?(n) }
1107
- return if names.empty?
1108
-
1109
- add_used_memory_names(names)
1110
- names
1111
- end
1112
-
1113
968
  # ── Guardrails ─────────────────────────────────────────────────────────────
1114
969
 
1115
970
  # Who can answer an approval: :repl, :worker or :non_interactive (the
@@ -1132,7 +987,7 @@ module Samagotchi
1132
987
 
1133
988
  # The metrics load the session's saved records from its state dir, once.
1134
989
  def bind_metrics(session)
1135
- return unless session.respond_to?(:id) && session.id
990
+ return unless session&.id
1136
991
 
1137
992
  @metrics.state_dir = session_state_dir
1138
993
  @metrics.session_id = session.id
@@ -1222,6 +1077,38 @@ module Samagotchi
1222
1077
  end
1223
1078
  private :record_last_turn
1224
1079
 
1080
+ # How every turn ends (completed, canceled, failed): the session gets
1081
+ # what the turn kept, goes idle and records the ending, and the end
1082
+ # event is announced, as one step of the event log (a snapshot taken
1083
+ # meanwhile, the Bridge's, shows the turn either in progress or in the
1084
+ # messages, never both or neither). Then the metrics are persisted.
1085
+ # The block runs in that step with the turn's seconds, once, and
1086
+ # returns [the messages to keep (nil: leave the session's), the end
1087
+ # event]. +save:+ also saves the session (a failed turn: a worker exits
1088
+ # after it). The disk writes come after the step: every emitter waits
1089
+ # for the event lock.
1090
+ def end_turn(turn, outcome, save: false)
1091
+ seconds = turn.elapsed
1092
+ synchronize_events do
1093
+ kept, event = yield(seconds)
1094
+ replace_session_messages(turn.session, kept) if kept
1095
+ turn.session.status = Session::STATUS_IDLE
1096
+ record_last_turn(turn.session, outcome, seconds, turn.origin)
1097
+ emit_event(turn.on_event, turn.tag(event))
1098
+ end
1099
+ turn.ended = true
1100
+ save_quietly(turn.session) if save
1101
+ @metrics.persist(state_dir: session_state_dir)
1102
+ end
1103
+ private :end_turn
1104
+
1105
+ def save_quietly(session)
1106
+ session.save(state_dir: session_state_dir)
1107
+ rescue StandardError
1108
+ nil
1109
+ end
1110
+ private :save_quietly
1111
+
1225
1112
  # Keep what the after_turn hooks presented as the answer's `display`
1226
1113
  # and tell the observers (:answer_display), after the turn_completed
1227
1114
  # they already had: the web re-reads the answer then. A turn_completed
@@ -1275,11 +1162,7 @@ module Samagotchi
1275
1162
  notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level }
1276
1163
  return hold_load_event(notice) && nil if @loading_plugins
1277
1164
 
1278
- sink = nil
1279
- in_turn = @activity_mutex.synchronize do
1280
- sink = @turn_event_sink
1281
- @turn_running
1282
- end
1165
+ in_turn, sink = @turn_state.in_turn_sink
1283
1166
  if anytime_thread?
1284
1167
  announce_anytime(notice.merge(between_turns: true))
1285
1168
  elsif current_init_task
@@ -1356,8 +1239,8 @@ module Samagotchi
1356
1239
 
1357
1240
  # Answer the pending question (called from UI thread; QuestionDesk#answer).
1358
1241
  # @return [Hash] normalized answer
1359
- def answer_question(id:, selected:, freeform: nil)
1360
- @question_desk.answer(id: id, selected: selected, freeform: freeform)
1242
+ def answer_question(id:, selected:, freeform: nil, client_id: nil)
1243
+ @question_desk.answer(id: id, selected: selected, freeform: freeform, client_id: client_id)
1361
1244
  end
1362
1245
 
1363
1246
  def set_question_sync_handler(&block)
@@ -1420,10 +1303,10 @@ module Samagotchi
1420
1303
  def session=(session)
1421
1304
  @session = session
1422
1305
  # One session per REPL/worker process: its records carry this sid.
1423
- Log.session_id = session.id if session.respond_to?(:id) && session.id
1306
+ Log.session_id = session.id if session&.id
1424
1307
  # A woken worker's /stats and status line count the turns before it.
1425
1308
  bind_metrics(session)
1426
- sync_used_memories_from_session(session)
1309
+ @used_memories.absorb(session)
1427
1310
  end
1428
1311
 
1429
1312
  # @return [IdleRecap, nil] the idle recap detector, or nil when disabled
@@ -1451,7 +1334,7 @@ module Samagotchi
1451
1334
  state = @recap&.state
1452
1335
  return nil unless state
1453
1336
 
1454
- messages = @activity_mutex.synchronize { @session&.messages } || []
1337
+ messages = @session&.messages || []
1455
1338
  covered = state[:covered].to_i
1456
1339
  turns_since = Array(messages).drop(covered).count { |m| Steer.turn_prompt?(m) }
1457
1340
  { text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
@@ -1473,10 +1356,27 @@ module Samagotchi
1473
1356
  self
1474
1357
  end
1475
1358
 
1359
+ # One turn's values, for #run_turn and its endings. Per turn and
1360
+ # single-threaded: the cross-thread turn state (the cancel slot, the
1361
+ # sink, steers) stays on the Engine.
1362
+ # +ended+: the end event is out (what follows is post-turn work).
1363
+ # +settings+: the kernel's LLM::TurnSettings, made in #prepare_turn and
1364
+ # set on the kernel in #generate.
1365
+ Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
1366
+ :settings) do
1367
+ # The turn's boundary events carry the origin only when there is one,
1368
+ # so payloads stay unchanged for callers that don't pass it.
1369
+ def tag(event) = origin ? event.merge(origin: origin) : event
1370
+
1371
+ # Seconds since the turn started (the cancel note says how long it ran).
1372
+ def elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
1373
+ end
1374
+ private_constant :Turn
1375
+
1476
1376
  # Run a single turn with event emission.
1477
1377
  #
1478
1378
  # Builds the system prompt + user messages, runs the kernel loop with
1479
- # event forwarding, and returns a KernelLoop::Result.
1379
+ # event forwarding, and returns its LLM::ModelResult.
1480
1380
  #
1481
1381
  # @param session [Session] the session to operate on
1482
1382
  # @param prompt [String] user input
@@ -1485,7 +1385,7 @@ module Samagotchi
1485
1385
  # @param cancel_controller [CancellationController, nil]
1486
1386
  # @param max_tool_output_chars [Integer, nil] per-output char cap for the
1487
1387
  # :tool_call_completed event's `output:` (nil → max_tool_output_chars)
1488
- # @return [KernelLoop::Result]
1388
+ # @return [LLM::ModelResult]
1489
1389
  # @param pending_input [#call, nil] optional drain proc returning
1490
1390
  # Array<String> of steering messages queued while the turn runs; drained
1491
1391
  # by the agentic loop at iteration boundaries (see KernelLoop#run).
@@ -1504,283 +1404,365 @@ module Samagotchi
1504
1404
  #
1505
1405
  # An Interrupt (SIGINT) cancels the turn: the pre-turn conversation plus
1506
1406
  # the prompt is kept in the session and :turn_canceled is emitted, then the
1507
- # Interrupt is re-raised so the caller still decides whether to exit. Any
1407
+ # Interrupt is re-raised so the caller still decides whether to exit. One
1408
+ # after the turn's end event (in its after_turn/session_end hooks) is
1409
+ # only re-raised: the turn already ended; so is any error there. Any
1508
1410
  # other error (e.g. an LLM::ProviderError) emits :turn_failed and
1509
1411
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
1510
1412
  # one-line summary:.
1511
1413
  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,
1512
1414
  images: [])
1415
+ turn = begin_turn(session, prompt, on_event: on_event, cancel_controller: cancel_controller, origin: origin,
1416
+ continue: continue)
1417
+ begin
1418
+ # A Stop cuts the /props probes this thread makes for the turn
1419
+ # (window, served model, vision) instead of waiting their timeout.
1420
+ probe_cancel_before = Client.swap_probe_cancel(turn.controller)
1421
+ return stopped_before_model(turn) unless prepare_turn(turn, continue ? [] : images)
1422
+
1423
+ result = generate(turn, max_iterations: max_iterations, max_tool_output_chars: max_tool_output_chars,
1424
+ pending_input: pending_input)
1425
+ publish_used_memories(session, on_event)
1426
+ complete_turn(turn, result)
1427
+ result
1428
+ rescue Interrupt
1429
+ # After the end event (in the after_turn/session_end hooks) the
1430
+ # turn is over: no second ending, the answer stays.
1431
+ raise if turn.ended
1432
+
1433
+ turn.controller.cancel!(:ctrl_c)
1434
+ end_turn(turn, "canceled") do |seconds|
1435
+ [ctrl_c_messages(turn, seconds), { type: :turn_canceled, cancellation_reason: :ctrl_c, duration_ms: (seconds * 1000).round }]
1436
+ end
1437
+ raise
1438
+ rescue StandardError => e
1439
+ # After the end event (post-turn work) likewise: logged and
1440
+ # re-raised, no turn_failed over the turn that ended.
1441
+ if turn.ended
1442
+ Log.warn(:turn, "post_turn_error", error: "#{e.class}: #{e.message}")
1443
+ raise
1444
+ end
1445
+
1446
+ # Keep what the turn got to (the prompt plus the loop's completed
1447
+ # tool iterations) like a cancel does, and save it: a worker exits
1448
+ # after a failed turn. The REPL still rolls back to its checkpoint.
1449
+ end_turn(turn, "failed", save: true) { |seconds| [failed_messages(turn, e), failed_event(e, seconds)] }
1450
+ raise
1451
+ ensure
1452
+ release_turn(probe_cancel_before)
1453
+ end
1454
+ end
1455
+
1456
+ # The turn's state before anything can fail it: the session running,
1457
+ # the turn flag, its cancel controller and sink. The cross-thread state
1458
+ # is set here and cleared in #release_turn only.
1459
+ def begin_turn(session, prompt, on_event:, cancel_controller:, origin:, continue:)
1513
1460
  # Track the active session for recap and status snapshot.
1514
1461
  @session = session
1515
1462
  # status is turn state: running now, idle again before the turn's end
1516
1463
  # is announced, so a UI reacting to that event reads the new state.
1517
1464
  session.status = Session::STATUS_RUNNING
1518
- sync_used_memories_from_session(session)
1519
- # Mark the turn running before generating so the idle recap detector does
1520
- # not fire (or render an invalidated recap) while the model is working,
1521
- # and drop a recap already in flight: the turn makes it stale.
1522
- set_turn_running(true)
1465
+ @used_memories.absorb(session)
1466
+ # Provide a cancellable controller for this turn (cross-process cancel via file flag)
1467
+ effective_controller = cancel_controller || CancellationController.new
1468
+ # The turn runs, with its controller and sink, in one step: a Stop
1469
+ # or a plugin's card from another thread never sees a running turn
1470
+ # without them. The idle recap detector does not fire (or render an
1471
+ # invalidated recap) while the model is working. A hook's notice goes
1472
+ # where the turn's events go (the REPL renders only its sink; a
1473
+ # worker's observers carry it to the bridge).
1474
+ @turn_state.begin!(controller: effective_controller, sink: on_event)
1475
+ # Drop a recap already in flight: the turn makes it stale.
1523
1476
  @recap&.invalidate!
1477
+ # The gate's context: who queued this turn, and git asked afresh.
1478
+ @guardrail_wiring.begin_turn(origin)
1479
+
1480
+ Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
1481
+ controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC))
1482
+ end
1483
+ private :begin_turn
1484
+
1485
+ # Everything before the model is asked: :turn_started, the kernel's
1486
+ # per-turn settings, the plugins' setup, the session_start/before_turn
1487
+ # hooks, then the messages to send (#turn_messages). False when a Stop
1488
+ # came before the hooks (the turn ends canceled there; before the
1489
+ # load-failure warnings when it came during the probes), else true.
1490
+ def prepare_turn(turn, images)
1491
+ session = turn.session
1492
+ image_refs, image_error = turn_image_refs(session, images)
1493
+ # Emit turn_started event
1494
+ bind_metrics(session)
1495
+ turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt }
1496
+ turn_started[:continue] = true if turn.continue
1497
+ turn_started[:images] = image_refs unless image_refs.empty?
1498
+ emit_event(turn.on_event, turn.tag(turn_started))
1499
+ raise image_error if image_error
1500
+
1524
1501
  # Ask the server for its window again each turn (one short /props GET,
1525
1502
  # cached across the turn's generations): a restart with another -c
1526
- # between turns raises no error that would drop the cache.
1527
- @client.invalidate_context_window! if @client.respond_to?(:invalidate_context_window!)
1503
+ # between turns raises no error that would drop the cache. The
1504
+ # profile may probe too (a first turn, a retry). Both run after
1505
+ # run_turn's probe-cancel swap, so a Stop cuts them, and a failure
1506
+ # ends the turn as turn_failed.
1507
+ @client.invalidate_context_window!
1528
1508
  refresh_profile!
1529
- # Provide a cancellable controller for this turn (cross-process cancel via file flag)
1530
- effective_controller = cancel_controller || CancellationController.new
1531
- @activity_mutex.synchronize do
1532
- @active_cancel_controller = effective_controller
1533
- # A hook's notice goes where the turn's events go (the REPL renders
1534
- # only its sink; a worker's observers carry it to the bridge).
1535
- @turn_event_sink = on_event
1509
+
1510
+ # Before anything of the turn is kept or a reminder is used up.
1511
+ turn.settings = turn_settings(session)
1512
+ refuse_images!(turn.settings.vision) unless image_refs.empty?
1513
+ # A Stop during the probes: the turn ends before the load-failure
1514
+ # warnings, which show once, so the next turn still shows them.
1515
+ return false if turn.controller.cancelled?
1516
+
1517
+ announce_guardrail_failures(turn.on_event)
1518
+ # Plugins' slow setup that brings tools (an MCP server's first
1519
+ # start): the turn waits for it here, before the system prompt
1520
+ # declares the tools; a Ctrl-C ends the wait (the turn is cancelled
1521
+ # at its first request).
1522
+ start_init_tasks!
1523
+ await_init_tasks(turn.controller, turn.on_event)
1524
+ # Plugins' tool sets that changed since the last turn.
1525
+ apply_staged_tools!
1526
+ # A Stop during the wait above: the turn ends here,
1527
+ # before the hooks run or a reminder is used up.
1528
+ return false if turn.controller.cancelled?
1529
+
1530
+ # Fire :session_start on the very first turn
1531
+ if @first_turn
1532
+ @hooks.fire(:session_start, { type: :session_start, session_id: session.id })
1533
+ @first_turn = false
1536
1534
  end
1537
- # The gate's context: who queued this turn, and git asked afresh.
1538
- @guardrail_wiring.begin_turn(origin)
1539
1535
 
1540
- prompt = nil if continue
1541
- # For the cancel note: how long the turn ran.
1542
- turn_started_clock = Process.clock_gettime(Process::CLOCK_MONOTONIC)
1543
- turn_seconds = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) - turn_started_clock }
1544
- messages = nil
1545
- # Boundary events carry the origin only when there is one, so payloads
1546
- # stay unchanged for callers that don't pass it.
1547
- with_origin = origin ? ->(event) { event.merge(origin: origin) } : ->(event) { event }
1548
- begin
1549
- # A Stop cuts the /props probes this thread makes for the turn
1550
- # (window, served model, vision) instead of waiting their timeout.
1551
- probe_cancel_before = Client.swap_probe_cancel(effective_controller)
1552
- image_refs, image_error = turn_image_refs(session, continue ? [] : images)
1553
- # Emit turn_started event
1554
- bind_metrics(session)
1555
- turn_started = { type: :turn_started, session_id: session.id, prompt: prompt }
1556
- turn_started[:continue] = true if continue
1557
- turn_started[:images] = image_refs unless image_refs.empty?
1558
- emit_event(on_event, with_origin.call(turn_started))
1559
- raise image_error if image_error
1560
-
1561
- # Before anything of the turn is kept or a reminder is used up.
1562
- vision = turn_vision(session)
1563
- @kernel.vision = vision if @kernel.respond_to?(:vision=)
1564
- @kernel.sampling = turn_sampling if @kernel.respond_to?(:sampling=)
1565
- thinking_target = @host_registry.resolve(@effective_model_name)
1566
- @turn_thinking = [thinking_level(thinking_target), thinking_target]
1567
- @kernel.thinking = @turn_thinking.first if @kernel.respond_to?(:thinking=)
1568
- announce_thinking_level(*@turn_thinking)
1569
- refuse_images!(vision) unless image_refs.empty?
1570
- announce_guardrail_failures(on_event)
1571
- # Plugins' slow setup that brings tools (an MCP server's first
1572
- # start): the turn waits for it here, before the system prompt
1573
- # declares the tools; a Ctrl-C ends the wait (the turn is cancelled
1574
- # at its first request).
1575
- start_init_tasks!
1576
- await_init_tasks(effective_controller, on_event)
1577
- # Plugins' tool sets that changed since the last turn.
1578
- apply_staged_tools!
1579
-
1580
- # Fire :session_start on the very first turn
1581
- if @first_turn
1582
- @hooks.fire(:session_start, { type: :session_start, session_id: session.id })
1583
- @first_turn = false
1584
- end
1536
+ # Fire :before_turn hook, with a read-only copy of the history so far
1537
+ # and the prompt (nil on a continue).
1538
+ @hooks.fire(:before_turn, { type: :before_turn, session_id: session.id, prompt: turn.prompt,
1539
+ messages: hook_messages(session.messages) })
1585
1540
 
1586
- # Fire :before_turn hook, with a read-only copy of the history so far
1587
- # and the prompt (nil on a continue).
1588
- @hooks.fire(:before_turn, { type: :before_turn, session_id: session.id, prompt: prompt,
1589
- messages: hook_messages(session.messages) })
1590
-
1591
- messages = session.messages.dup
1592
- # Built once per Engine (and again after a model switch) so the prompt
1593
- # prefix, and the model server's KV cache for it, stay stable.
1594
- messages = ContextNote.with_system_head(messages, { role: "system", content: system_prompt })
1595
- # Explicit --memory preloads are now known after system prompt build.
1596
- add_used_memory_names(activated_memory_names)
1597
- sync_used_memories_from_session(session)
1598
-
1599
- # Inject due reminders as a tail system message (after history, before
1600
- # the new user prompt) to preserve prefix KV cache. Mutating the head
1601
- # system prompt invalidates cache for the entire prefix.
1602
- due_reminders = collect_due_reminders(messages)
1603
- if due_reminders.any?
1604
- emit_event(on_event, {
1605
- type: :reminder_injected,
1606
- reminders: due_reminders
1607
- })
1608
- end
1541
+ turn_messages(turn, image_refs)
1542
+ true
1543
+ end
1544
+ private :prepare_turn
1609
1545
 
1610
- unless continue
1611
- user_message = { role: "user", content: prompt }
1612
- user_message[:images] = image_refs unless image_refs.empty?
1613
- messages << user_message
1614
- session.last_prompt = prompt
1615
- end
1546
+ # A turn stopped before the model was asked (#prepare_turn returned
1547
+ # false): canceled with nothing kept, like a Ctrl-C there; the result
1548
+ # has no conversation, so a UI's checkpoint stands.
1549
+ def stopped_before_model(turn)
1550
+ reason = turn.controller.reason
1551
+ end_turn(turn, "canceled") do |seconds|
1552
+ [nil, { type: :turn_canceled, cancellation_reason: reason, duration_ms: (seconds * 1000).round }]
1553
+ end
1554
+ LLM::ModelResult.new(text: "", canceled: true, cancellation_reason: reason)
1555
+ end
1556
+ private :stopped_before_model
1557
+
1558
+ # The turn's settings for the kernel (LLM::TurnSettings): vision,
1559
+ # sampling and thinking, the thinking resolved before the hooks run.
1560
+ # The model name is added in #generate, after the hooks that may switch
1561
+ # the model.
1562
+ def turn_settings(session)
1563
+ vision = turn_vision(session)
1564
+ sampling = turn_sampling
1565
+ thinking_target = @host_registry.resolve(@effective_model_name)
1566
+ @turn_thinking = [thinking_level(thinking_target), thinking_target]
1567
+ announce_thinking_level(*@turn_thinking)
1568
+ LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil)
1569
+ end
1570
+ private :turn_settings
1571
+
1572
+ # The messages the turn sends: the history under the system head, due
1573
+ # reminders, the prompt. turn.messages is set first and grown in place,
1574
+ # so a Ctrl-C or a failure meanwhile keeps what is there.
1575
+ def turn_messages(turn, image_refs)
1576
+ session = turn.session
1577
+ turn.messages = session.messages.dup
1578
+ # Built once per Engine (and again after a model switch) so the prompt
1579
+ # prefix, and the model server's KV cache for it, stay stable.
1580
+ turn.messages = ContextNote.with_system_head(turn.messages, { role: "system", content: system_prompt })
1581
+ # Explicit --memory preloads are now known after system prompt build.
1582
+ @used_memories.add(activated_memory_names).absorb(session)
1583
+
1584
+ # Inject due reminders as a tail system message (after history, before
1585
+ # the new user prompt) to preserve prefix KV cache. Mutating the head
1586
+ # system prompt invalidates cache for the entire prefix.
1587
+ due_reminders = @reminder_queue.inject!(turn.messages)
1588
+ if due_reminders.any?
1589
+ emit_event(turn.on_event, {
1590
+ type: :reminder_injected,
1591
+ reminders: due_reminders
1592
+ })
1593
+ end
1616
1594
 
1617
- sync_kernel_client!
1618
- # Route model name as bare (without host prefix) to the transport;
1619
- # host selection already done via active client.
1620
- bare_for_backend = bare_model_name(@effective_model_name)
1621
- # The chat loop dispatches tools through the kernel without its #run:
1622
- # tag those dumps with this turn's model, not the last native one.
1623
- @kernel.current_model_name = bare_for_backend if @kernel.respond_to?(:current_model_name=)
1624
-
1625
- result = backend.complete(
1626
- messages: messages,
1627
- max_iterations: @no_interrupt ? NO_INTERRUPT_MAX_ITERATIONS : max_iterations,
1628
- on_stream_event: build_stream_event_handler(on_event, cancel_controller: effective_controller),
1629
- cancel_controller: effective_controller,
1630
- model_name: bare_for_backend,
1631
- max_tool_output_chars: max_tool_output_chars,
1632
- pending_input: turn_drain(pending_input)
1633
- )
1634
-
1635
- # Persist deduped used memories onto the session for Web + reload.
1636
- begin
1637
- session.used_memory_names = used_memory_names
1638
- rescue StandardError
1639
- nil
1640
- end
1641
- # Notify live observers of the updated memory list (so yellow bar refreshes
1642
- # even without a tool_call event if the preload was the only addition).
1643
- # Only emit when there is something to report to avoid noisy event_count drift.
1644
- if used_memory_names.any?
1645
- begin
1646
- emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
1647
- rescue StandardError
1648
- nil
1649
- end
1650
- end
1595
+ return if turn.continue
1651
1596
 
1652
- response = result.respond_to?(:output) ? result.output.to_s : result.to_s
1653
- canceled = result.respond_to?(:canceled?) && result.canceled?
1654
- conversation = result.conversation if result.respond_to?(:conversation) && result.conversation.is_a?(Array)
1655
- # Bring the turn into the session and announce its end as one step of
1656
- # the event log: a snapshot taken meanwhile (the Bridge's) shows the
1657
- # turn either in progress or in the messages, never both or neither.
1658
- # A turn that ran out of iterations ends at its tool results, so a
1659
- # continue resumes from them rather than after a made-up reply.
1660
- resumable = result.respond_to?(:resumable?) && result.resumable?
1661
- # Nothing visible (the native loop: no text; the chat loop: its
1662
- # placeholder text) is a turn the model should know ended that way.
1663
- empty = !canceled && !resumable &&
1664
- (response.strip.empty? || (result.respond_to?(:empty_answer?) && result.empty_answer?))
1665
- # after_turn hooks run below and may present the answer (the web
1666
- # holds its pop until it knows).
1667
- display_pending = !canceled && @hooks.any?(:after_turn)
1668
- synchronize_events do
1669
- if empty
1670
- # The placeholder is for the UIs (a new array: it must not leak
1671
- # into the result); the note is for the model, so it goes on the
1672
- # result's conversation too, which the REPL keeps as-is.
1673
- # A retry's nudge at the tail goes: this note says it all.
1674
- note = TurnNote.empty
1675
- conversation&.replace(TurnNote.without_trailing(conversation))
1676
- saved = TurnNote.without_trailing(conversation || session.messages)
1677
- saved << { role: "model", content: "[No response]" } if response.strip.empty?
1678
- replace_session_messages(session, saved + [note])
1679
- conversation << note if conversation
1680
- elsif canceled && conversation
1681
- conversation << TurnNote.cancelled(result.cancellation_reason, seconds: turn_seconds.call,
1682
- stopped_by: effective_controller.detail,
1683
- shown: TurnNote.interrupted_tail?(conversation),
1684
- running_tasks: Tools::TaskRuntime.running_created_in(conversation))
1685
- replace_session_messages(session, conversation)
1686
- elsif conversation
1687
- replace_session_messages(session, conversation)
1688
- end
1689
- session.status = Session::STATUS_IDLE
1690
- record_last_turn(session, canceled ? "canceled" : "completed", turn_seconds.call, origin)
1691
- if canceled
1692
- emit_event(on_event, with_origin.call({
1693
- type: :turn_canceled,
1694
- cancellation_reason: result.cancellation_reason,
1695
- duration_ms: (turn_seconds.call * 1000).round
1696
- }))
1697
- else
1698
- # For a client that attaches later (session_state_snapshot).
1699
- @last_context_status = result.context_status.dup if result.context_status
1700
- emit_event(on_event, with_origin.call({
1701
- type: :turn_completed,
1702
- result: result,
1703
- turn_summary: turn_summary(result),
1704
- display_pending: display_pending
1705
- }))
1706
- end
1707
- end
1708
- @metrics.persist(state_dir: session_state_dir)
1597
+ user_message = { role: "user", content: turn.prompt }
1598
+ user_message[:images] = image_refs unless image_refs.empty?
1599
+ turn.messages << user_message
1600
+ session.last_prompt = turn.prompt
1601
+ end
1602
+ private :turn_messages
1709
1603
 
1710
- # Fire :after_turn hook (runs even on cancel/success), with a read-only
1711
- # copy of the conversation the turn stored, and event[:present] for
1712
- # a display version of the answer (AnswerDisplay).
1713
- display = AnswerDisplay.new(session.messages)
1714
- after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
1715
- messages: hook_messages(session.messages) }
1716
- after_turn[:present] = display.presenter(after_turn)
1717
- @hooks.fire(:after_turn, after_turn)
1718
- store_answer_display(session, display, pending: display_pending)
1604
+ # Ask the effective model's backend.
1605
+ def generate(turn, max_iterations:, max_tool_output_chars:, pending_input:)
1606
+ sync_kernel_client!
1607
+ # Route model name as bare (without host prefix) to the transport;
1608
+ # host selection already done via active client.
1609
+ bare_for_backend = bare_model_name(@effective_model_name)
1610
+ # The turn's settings, set once here. The chat loop dispatches tools
1611
+ # through the kernel without its #run: tag those dumps with this
1612
+ # turn's model, not the last native one.
1613
+ @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend)
1614
+
1615
+ backend.complete(
1616
+ messages: turn.messages,
1617
+ max_iterations: @no_interrupt ? NO_INTERRUPT_MAX_ITERATIONS : max_iterations,
1618
+ on_stream_event: build_stream_event_handler(turn.on_event, cancel_controller: turn.controller),
1619
+ cancel_controller: turn.controller,
1620
+ model_name: bare_for_backend,
1621
+ max_tool_output_chars: max_tool_output_chars,
1622
+ pending_input: turn_drain(pending_input)
1623
+ )
1624
+ end
1625
+ private :generate
1719
1626
 
1720
- # Fire :session_end after every turn (turn-level lifecycle)
1721
- @hooks.fire(:session_end, { type: :session_end, session_id: session.id })
1627
+ def publish_used_memories(session, on_event)
1628
+ # Persist deduped used memories onto the session for Web + reload.
1629
+ begin
1630
+ session.used_memory_names = used_memory_names
1631
+ rescue StandardError
1632
+ nil
1633
+ end
1634
+ # Notify live observers of the updated memory list (so yellow bar refreshes
1635
+ # even without a tool_call event if the preload was the only addition).
1636
+ # Only emit when there is something to report to avoid noisy event_count drift.
1637
+ return unless used_memory_names.any?
1722
1638
 
1723
- result
1724
- rescue Interrupt
1725
- effective_controller.cancel!(:ctrl_c)
1726
- synchronize_events do
1727
- # Only the pre-turn messages survive here, so tasks this turn
1728
- # started are missed (plan wait-stop §6).
1729
- if messages
1730
- note = TurnNote.cancelled(:ctrl_c, seconds: turn_seconds.call,
1731
- running_tasks: Tools::TaskRuntime.running_created_in(messages))
1732
- replace_session_messages(session, TurnNote.replace_trailing(messages, note))
1733
- end
1734
- session.status = Session::STATUS_IDLE
1735
- record_last_turn(session, "canceled", turn_seconds.call, origin)
1736
- emit_event(on_event, with_origin.call({ type: :turn_canceled, cancellation_reason: :ctrl_c,
1737
- duration_ms: (turn_seconds.call * 1000).round }))
1738
- end
1739
- @metrics.persist(state_dir: session_state_dir)
1740
- raise
1741
- rescue StandardError => e
1742
- # Keep what the turn got to (the prompt plus the loop's completed
1743
- # tool iterations) like a cancel does, and save it: a worker exits
1744
- # after a failed turn. The REPL still rolls back to its checkpoint.
1745
- kept = e.respond_to?(:partial_conversation) && e.partial_conversation.is_a?(Array) ? e.partial_conversation : messages
1746
- # The model reads why on its next turn (a UI that rolls the turn back
1747
- # leaves its own note, TurnFlow#prompt_turn_failed). Nothing when the
1748
- # turn never reached the model (kept is nil).
1749
- if kept
1750
- summary = e.respond_to?(:summary) ? e.summary : e.message
1751
- kept = TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: continue))
1752
- end
1753
- replace_session_messages(session, kept) if kept
1754
- session.status = Session::STATUS_IDLE
1755
- record_last_turn(session, "failed", turn_seconds.call, origin)
1756
- begin; session.save(state_dir: session_state_dir); rescue StandardError; nil; end
1757
- failed = { type: :turn_failed, error_class: e.class.name, message: e.message,
1758
- duration_ms: (turn_seconds.call * 1000).round }
1759
- # A provider error says what kind it is, for one line per kind in the UIs.
1760
- if e.is_a?(LLM::ProviderError)
1761
- failed.merge!(error_kind: e.kind, retryable: e.retryable?, host: e.host, summary: e.summary)
1762
- end
1763
- emit_event(on_event, with_origin.call(failed))
1764
- @metrics.persist(state_dir: session_state_dir)
1765
- raise
1766
- ensure
1767
- # A completed turn is activity: release the turn flag and advance the
1768
- # shared inactivity clock so the idle recap detector (shared with the REPL)
1769
- # treats the just-finished turn as activity and re-arms its window.
1770
- # Always runs, even if an exception occurred.
1771
- set_turn_running(false)
1772
- Client.swap_probe_cancel(probe_cancel_before)
1773
- left = @activity_mutex.synchronize do
1774
- @active_cancel_controller = nil
1775
- @turn_event_sink = nil
1776
- @steers.tap { @steers = [] }
1639
+ begin
1640
+ emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names })
1641
+ rescue StandardError
1642
+ nil
1643
+ end
1644
+ end
1645
+ private :publish_used_memories
1646
+
1647
+ # The normal ending (an answer, an empty answer, a Stop, the iteration
1648
+ # limit), then the after-turn hooks.
1649
+ def complete_turn(turn, result)
1650
+ canceled = result.canceled?
1651
+ # after_turn hooks run below and may present the answer (the web
1652
+ # holds its pop until it knows).
1653
+ display_pending = !canceled && @hooks.any?(:after_turn)
1654
+ end_turn(turn, canceled ? "canceled" : "completed") do |seconds|
1655
+ kept = kept_messages(turn, result, seconds)
1656
+ if canceled
1657
+ [kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason,
1658
+ duration_ms: (seconds * 1000).round }]
1659
+ else
1660
+ # For a client that attaches later (session_state_snapshot).
1661
+ @last_context_status = result.context_status.dup if result.context_status
1662
+ [kept, { type: :turn_completed, result: result, turn_summary: turn_summary(result),
1663
+ display_pending: display_pending }]
1777
1664
  end
1778
- log_dropped_steers(left, "turn_ended")
1779
- record_activity
1780
- # Clear hooks so they remain turn-scoped and never leak into the next turn.
1781
- clear_hooks
1665
+ end
1666
+
1667
+ # Fire :after_turn hook (an answer or a Stop; an Interrupt or a failure
1668
+ # ends the turn in #run_turn without it), with a read-only
1669
+ # copy of the conversation the turn stored, and event[:present] for
1670
+ # a display version of the answer (AnswerDisplay).
1671
+ session = turn.session
1672
+ display = AnswerDisplay.new(session.messages)
1673
+ after_turn = { type: :after_turn, status: canceled ? "canceled" : "completed",
1674
+ messages: hook_messages(session.messages) }
1675
+ after_turn[:present] = display.presenter(after_turn)
1676
+ @hooks.fire(:after_turn, after_turn)
1677
+ store_answer_display(session, display, pending: display_pending)
1678
+
1679
+ # Fire :session_end after the turn (turn-level lifecycle), as :after_turn
1680
+ @hooks.fire(:session_end, { type: :session_end, session_id: session.id })
1681
+ end
1682
+ private :complete_turn
1683
+
1684
+ # What the session keeps of a turn that ended normally (nil: nothing to
1685
+ # replace). Runs under the event lock: it also adds the turn's note to
1686
+ # result.conversation, which the REPL keeps as-is.
1687
+ # A turn that ran out of iterations ends at its tool results, so a
1688
+ # continue resumes from them rather than after a made-up reply.
1689
+ def kept_messages(turn, result, seconds)
1690
+ conversation = result.conversation if result.conversation.is_a?(Array)
1691
+ # Nothing visible (the native loop: no text; the chat loop: its
1692
+ # placeholder text) is a turn the model should know ended that way.
1693
+ if result.empty_answer?
1694
+ # The placeholder is for the UIs (a new array: it must not leak
1695
+ # into the result); the note is for the model, so it goes on the
1696
+ # result's conversation too.
1697
+ # A retry's nudge at the tail goes: this note says it all.
1698
+ note = TurnNote.empty
1699
+ conversation&.replace(TurnNote.without_trailing(conversation))
1700
+ saved = TurnNote.without_trailing(conversation || turn.session.messages)
1701
+ saved << { role: "model", content: "[No response]" } if result.output.to_s.strip.empty?
1702
+ conversation << note if conversation
1703
+ saved + [note]
1704
+ elsif result.canceled? && conversation
1705
+ conversation << TurnNote.cancelled(result.cancellation_reason, seconds: seconds,
1706
+ stopped_by: turn.controller.detail,
1707
+ shown: TurnNote.interrupted_tail?(conversation),
1708
+ running_tasks: Tools::TaskRuntime.running_created_in(conversation))
1709
+ else
1710
+ conversation
1782
1711
  end
1783
1712
  end
1713
+ private :kept_messages
1714
+
1715
+ # A Ctrl-C keeps the turn's messages so far with a cancel note (nil
1716
+ # before there are any). Only the pre-turn messages survive here, so
1717
+ # tasks this turn started are missed (plan wait-stop §6).
1718
+ def ctrl_c_messages(turn, seconds)
1719
+ return nil unless turn.messages
1720
+
1721
+ note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(turn.messages))
1722
+ TurnNote.replace_trailing(turn.messages, note)
1723
+ end
1724
+ private :ctrl_c_messages
1725
+
1726
+ # A failure keeps the loop's partial conversation (else the turn's
1727
+ # messages so far) with a failed note; nil when the turn never reached
1728
+ # the model. The model reads why on its next turn (a UI that rolls the
1729
+ # turn back leaves its own note, TurnFlow#prompt_turn_failed).
1730
+ def failed_messages(turn, error)
1731
+ kept = error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array) ? error.partial_conversation : turn.messages
1732
+ return nil unless kept
1733
+
1734
+ summary = error.respond_to?(:summary) ? error.summary : error.message
1735
+ TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: turn.continue))
1736
+ end
1737
+ private :failed_messages
1738
+
1739
+ def failed_event(error, seconds)
1740
+ failed = { type: :turn_failed, error_class: error.class.name, message: error.message,
1741
+ duration_ms: (seconds * 1000).round }
1742
+ # A provider error says what kind it is, for one line per kind in the UIs.
1743
+ if error.is_a?(LLM::ProviderError)
1744
+ failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: error.summary)
1745
+ end
1746
+ failed
1747
+ end
1748
+ private :failed_event
1749
+
1750
+ # However the turn ended (#run_turn's ensure): the turn flag off, the
1751
+ # probe cancel restored, the cancel controller, sink and steers cleared
1752
+ # (a steer left is logged as dropped), activity recorded, the
1753
+ # turn-scoped hooks cleared.
1754
+ def release_turn(probe_cancel_before)
1755
+ # A completed turn is activity: release the turn flag and advance the
1756
+ # shared inactivity clock so the idle recap detector (shared with the REPL)
1757
+ # treats the just-finished turn as activity and re-arms its window.
1758
+ left = @turn_state.finish!
1759
+ Client.swap_probe_cancel(probe_cancel_before)
1760
+ log_dropped_steers(left, "turn_ended")
1761
+ record_activity
1762
+ # Clear hooks so they remain turn-scoped and never leak into the next turn.
1763
+ clear_hooks
1764
+ end
1765
+ private :release_turn
1784
1766
 
1785
1767
  # [refs, nil] for a turn's images, or [[], error] when one can't be used
1786
1768
  # (the turn then fails right after :turn_started).
@@ -1839,7 +1821,7 @@ module Samagotchi
1839
1821
  return unless target&.entry&.chat? && Thinking::EFFORTS.include?(level)
1840
1822
 
1841
1823
  client = target.client
1842
- props = client.respond_to?(:cached_server_props) ? client.cached_server_props(model: target.bare_model) : nil
1824
+ props = client.cached_server_props(model: target.bare_model)
1843
1825
  return unless Thinking.effort_ignored?(level, props)
1844
1826
 
1845
1827
  thinking_notice_once(:unsupported, target, :info,
@@ -1964,8 +1946,16 @@ module Samagotchi
1964
1946
 
1965
1947
  private
1966
1948
 
1949
+ # The one named seam for replacing a session's conversation (the
1950
+ # array is replaced, never mutated in place). No lock: a reader gets
1951
+ # the old array or the new one, and both are whole.
1952
+ # The turn as other threads see it (TurnState).
1953
+ attr_reader :turn_state
1954
+ # The due reminders on their way into a turn (ReminderQueue).
1955
+ attr_reader :reminder_queue
1956
+
1967
1957
  def replace_session_messages(session, messages)
1968
- @activity_mutex.synchronize { session.messages = messages }
1958
+ session.messages = messages
1969
1959
  end
1970
1960
 
1971
1961
  # Pick the loop for a target; the chat loop is pointed at the target
@@ -2008,7 +1998,8 @@ module Samagotchi
2008
1998
  end
2009
1999
  begin
2010
2000
  Hooks::BundleLoader.load(bundle_name: bundle_name, hooks_dir: hooks_dir, metadata: data[:hooks], registry: @hooks,
2011
- failures: @guardrail_failures, settings: settings[bundle_name.to_s] || {})
2001
+ failures: @guardrail_failures, settings: settings[bundle_name.to_s] || {},
2002
+ requires_chi: data[:requires_chi])
2012
2003
  rescue Exception => e
2013
2004
  Log.error(:hooks, "bundle_load_failed", echo: "[samagotchi:hooks] bundle '#{bundle_name}' failed to load hooks: #{e.class}: #{e.message}", bundle: bundle_name, error: e.class.name)
2014
2005
  end
@@ -2081,11 +2072,9 @@ module Samagotchi
2081
2072
  ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:|
2082
2073
  hook_ask_user(question, options, header, allow_freeform, hook)
2083
2074
  },
2084
- # Nothing to cancel while the Engine is still being built (a
2085
- # server that starts as its plugin loads).
2086
2075
  # An init task's is its own (a Ctrl-C ends a turn, not the task).
2087
2076
  cancelled: lambda {
2088
- (task = current_init_task) ? task.cancelled? : @activity_mutex && active_cancel_controller&.cancelled?
2077
+ (task = current_init_task) ? task.cancelled? : active_cancel_controller&.cancelled?
2089
2078
  },
2090
2079
  card: ->(**card) { show_card(**card) },
2091
2080
  steer: ->(text, label) { steer(text, source: hook_source(label)) },
@@ -2159,7 +2148,7 @@ module Samagotchi
2159
2148
  @auto_turn_callback = auto_turn_callback
2160
2149
  IdleReminders.new(
2161
2150
  engine: self,
2162
- reminder_store: @reminder_store,
2151
+ queue: @reminder_queue,
2163
2152
  callback: @auto_turn_callback
2164
2153
  )
2165
2154
  end
@@ -2221,7 +2210,7 @@ module Samagotchi
2221
2210
  def emit_event(on_event, event)
2222
2211
  # Capture used memories synchronously in the turn thread.
2223
2212
  read_names = begin
2224
- capture_used_memory_from_event(event)
2213
+ @used_memories.capture(event, muted: @muted_memory_names)
2225
2214
  rescue StandardError
2226
2215
  nil
2227
2216
  end
@@ -2253,7 +2242,7 @@ module Samagotchi
2253
2242
  adapter = nil
2254
2243
  if target.entry.chat?
2255
2244
  adapter = @host_registry.adapter_for(target.entry)
2256
- client = nil if adapter.respond_to?(:remote?) && adapter.remote?
2245
+ client = nil if adapter.remote?
2257
2246
  end
2258
2247
  ContextWindow.resolve(client: client, model: target.bare_model, adapter: adapter)
2259
2248
  rescue StandardError
@@ -2271,9 +2260,9 @@ module Samagotchi
2271
2260
  client = target.client
2272
2261
  if target.entry.chat?
2273
2262
  adapter = @host_registry.adapter_for(target.entry)
2274
- return [nil, nil] if adapter.respond_to?(:remote?) && adapter.remote?
2263
+ return [nil, nil] if adapter.remote?
2275
2264
  end
2276
- served = ServedModel.from_props(client.server_props(model: target.bare_model)) if client.respond_to?(:server_props)
2265
+ served = ServedModel.from_props(client.server_props(model: target.bare_model))
2277
2266
  served ? [served, asked] : [nil, nil]
2278
2267
  rescue StandardError
2279
2268
  [nil, nil]
@@ -2300,7 +2289,7 @@ module Samagotchi
2300
2289
  # prompt format and parser, and the system prompts built for the old one.
2301
2290
  def apply_profile(resolution)
2302
2291
  @prompt_builder&.reset! if @profile_resolution && @profile_resolution.profile.name != resolution.profile.name
2303
- @kernel.use_profile!(resolution) if @kernel.respond_to?(:use_profile!)
2292
+ @kernel.use_profile!(resolution)
2304
2293
  resolution
2305
2294
  end
2306
2295