samagotchi 0.11.0 → 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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +29 -1
  3. data/bin/chi +5 -2
  4. data/docs/architecture.md +4 -4
  5. data/docs/cli.md +25 -1
  6. data/docs/desktop.md +24 -2
  7. data/lib/samagotchi/bridge.rb +4 -4
  8. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  9. data/lib/samagotchi/desktop/macos/App.swift +5 -1
  10. data/lib/samagotchi/desktop/macos/ChiRunner.swift +19 -0
  11. data/lib/samagotchi/desktop/macos/Models.swift +238 -0
  12. data/lib/samagotchi/desktop/macos/Panel.swift +301 -42
  13. data/lib/samagotchi/engine.rb +445 -482
  14. data/lib/samagotchi/host_registry.rb +26 -2
  15. data/lib/samagotchi/idle_reminders.rb +13 -36
  16. data/lib/samagotchi/kernel_loop.rb +82 -120
  17. data/lib/samagotchi/llm/chat_loop.rb +17 -38
  18. data/lib/samagotchi/llm/model_result.rb +14 -20
  19. data/lib/samagotchi/llm/native_backend.rb +3 -21
  20. data/lib/samagotchi/llm/openai_chat.rb +1 -1
  21. data/lib/samagotchi/llm/turn_settings.rb +19 -0
  22. data/lib/samagotchi/llm/usage.rb +2 -57
  23. data/lib/samagotchi/memory_bundle/installer.rb +1 -1
  24. data/lib/samagotchi/model_catalog.rb +101 -0
  25. data/lib/samagotchi/model_profile.rb +1 -1
  26. data/lib/samagotchi/models_command.rb +112 -0
  27. data/lib/samagotchi/reminder_queue.rb +73 -0
  28. data/lib/samagotchi/reminder_store.rb +3 -3
  29. data/lib/samagotchi/self_report.rb +8 -1
  30. data/lib/samagotchi/send_command.rb +1 -1
  31. data/lib/samagotchi/session_commands.rb +5 -5
  32. data/lib/samagotchi/session_manager.rb +2 -6
  33. data/lib/samagotchi/terminal_ui.rb +8 -28
  34. data/lib/samagotchi/thinking.rb +1 -1
  35. data/lib/samagotchi/tool_runner.rb +4 -6
  36. data/lib/samagotchi/tools/builtins.rb +1 -1
  37. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  38. data/lib/samagotchi/turn_flow.rb +4 -4
  39. data/lib/samagotchi/turn_state.rb +126 -0
  40. data/lib/samagotchi/used_memories.rb +88 -0
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/vision_support.rb +4 -4
  43. data/lib/samagotchi/web/app.rb +14 -33
  44. data/lib/samagotchi/web/session_hub.rb +0 -4
  45. data/lib/samagotchi/web/session_summary.rb +6 -6
  46. data/lib/samagotchi/worker.rb +1 -1
  47. metadata +8 -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,41 @@ 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=)
225
+ @kernel.muted_memory_names = @muted_memory_names
214
226
  # Keep kernel client in sync with active host via setter
215
227
  @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
228
  # 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=)
229
+ @kernel.question_handler = proc { |payload| request_question(payload) }
227
230
  # Every tool call asks this gate first. The kernel is never rebuilt, so
228
231
  # it holds across model switches.
229
232
  self.guardrail_state_dir = Session.default_state_dir
230
233
  # 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=)
234
+ @kernel.peers = PeerView.new(self)
235
+ @kernel.guardrail_gate = @guardrail_wiring.gate
233
236
  # What a hook can do beyond reading its event (event[:notify],
234
237
  # event[:ask_user], event[:stop_turn]): the Engine's routes to the UIs.
235
238
  @hooks.runtime = hook_runtime
@@ -244,29 +247,18 @@ module Samagotchi
244
247
  @session = nil
245
248
  @session_observer = SessionObserver.new
246
249
  @metrics = SessionMetrics.new
247
- @used_memory_names = []
248
- @used_memory_mutex = Monitor.new
250
+ @used_memories = UsedMemories.new
249
251
  # 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
252
+ if @resume_session
253
+ @used_memories.absorb(@resume_session)
252
254
  @session = @resume_session
253
255
  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 = []
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!
270
262
  @recap = RecapSetup.build(recap, engine: self, host_registry: @host_registry,
271
263
  session_target: -> { session_model_recap_target },
272
264
  session_id: -> { @session&.id }, state_dir: -> { session_state_dir })
@@ -296,37 +288,29 @@ module Samagotchi
296
288
  backend_for(@host_registry.resolve(@effective_model_name))
297
289
  end
298
290
 
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.
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.
302
294
  # @param now [Float, nil] injectable monotonic time (defaults to now)
303
295
  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
296
+ @turn_state.record_activity(now)
308
297
  end
309
298
 
310
299
  # @return [Float] monotonic seconds of the last recorded activity
311
300
  def last_activity_at
312
- @activity_mutex.synchronize { @last_activity_at }
301
+ @turn_state.last_activity_at
313
302
  end
314
303
 
315
304
  # @return [Integer] monotonically-increasing activity counter (advanced by
316
305
  # #record_activity; lets the idle detector summarize once per idle window)
317
306
  def activity_seq
318
- @activity_mutex.synchronize { @activity_seq }
307
+ @turn_state.activity_seq
319
308
  end
320
309
 
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
310
+ # @return [Boolean] true while a turn is in flight (the idle jobs never
311
+ # fire, nor render, while the model is generating)
328
312
  def turn_running?
329
- @activity_mutex.synchronize { @turn_running }
313
+ @turn_state.running?
330
314
  end
331
315
 
332
316
  # Put +text+ into the running turn, as a UI's steering does: it joins the
@@ -337,15 +321,7 @@ module Samagotchi
337
321
  # the turn ends first, it is dropped (logged).
338
322
  # @return [Boolean] whether it was queued
339
323
  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
324
+ @turn_state.steer(text, source: source)
349
325
  end
350
326
 
351
327
  # The drain a turn's loop gets: the caller's lines (a UI's steering; nil
@@ -362,11 +338,7 @@ module Samagotchi
362
338
  private :turn_drain
363
339
 
364
340
  def take_steers(drop)
365
- steers = @activity_mutex.synchronize do
366
- taken = @steers
367
- @steers = []
368
- taken
369
- end
341
+ steers = @turn_state.take_steers
370
342
  return steers unless drop
371
343
 
372
344
  log_dropped_steers(steers, "answered")
@@ -383,41 +355,37 @@ module Samagotchi
383
355
 
384
356
  # @return [Array<String>] reminder names queued for a synthetic REPL turn
385
357
  def due_reminder_names
386
- @activity_mutex.synchronize { @due_reminder_names.dup }
358
+ @reminder_queue.pending_names
387
359
  end
388
360
 
389
361
  # Queue reminder names for a synthetic REPL turn (IdleReminders callback).
390
362
  def note_due_reminders(names)
391
- @activity_mutex.synchronize { @due_reminder_names = Array(names).dup }
363
+ @reminder_queue.note_pending(names)
392
364
  end
393
365
 
394
366
  def clear_due_reminder_names!
395
- @activity_mutex.synchronize { @due_reminder_names = [] }
367
+ @reminder_queue.clear_pending!
396
368
  end
397
369
 
398
370
  # @return [CancellationController, nil] active turn's cancellation controller
399
371
  def active_cancel_controller
400
- @activity_mutex.synchronize { @active_cancel_controller }
372
+ @turn_state.controller
401
373
  end
402
374
 
403
375
  # Cancel the currently running turn, if any.
404
376
  # @param reason [Symbol] cancellation reason
405
377
  # @return [Boolean] whether a cancellation was triggered
406
378
  def cancel_current_turn!(reason = :manual)
407
- ctrl = active_cancel_controller
408
- return false unless ctrl
409
-
410
- ctrl.cancel!(reason)
379
+ @turn_state.cancel!(reason)
411
380
  end
412
381
 
413
382
  # 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.
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.
418
386
  # @return [String] JSON array of the messages
419
387
  def messages_json_for_recap
420
- snapshot = @activity_mutex.synchronize { @session&.messages }
388
+ snapshot = @session&.messages
421
389
  return "[]" if snapshot.nil?
422
390
 
423
391
  JSON.generate(AnswerDisplay.strip_all(snapshot).map(&:dup))
@@ -453,7 +421,7 @@ module Samagotchi
453
421
  def store_model!(session)
454
422
  ref = effective_model_ref
455
423
  session.model_name = ref
456
- session.model_typed = @typed_model_name.to_s == ref ? nil : @typed_model_name if session.respond_to?(:model_typed=)
424
+ session.model_typed = @typed_model_name.to_s == ref ? nil : @typed_model_name
457
425
  session
458
426
  end
459
427
 
@@ -495,7 +463,7 @@ module Samagotchi
495
463
  @model_key = ModelOverlay.key_for(bare_model_name(@effective_model_name))
496
464
  typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(@typed_model_name).last)
497
465
  fallback = typed_key == @model_key ? nil : typed_key
498
- @kernel.sync_model_key!(@model_key, fallback: fallback) if @kernel.respond_to?(:sync_model_key!)
466
+ @kernel.sync_model_key!(@model_key, fallback: fallback)
499
467
  end
500
468
  private :sync_model_key!
501
469
 
@@ -511,7 +479,7 @@ module Samagotchi
511
479
  def sync_kernel_client!
512
480
  target = @host_registry.resolve(@effective_model_name)
513
481
  @client = target.client
514
- @kernel.client = target.client if @kernel.respond_to?(:client=) && @kernel.client != target.client
482
+ @kernel.client = target.client if @kernel.client != target.client
515
483
  backend_for(target)
516
484
  end
517
485
 
@@ -609,11 +577,7 @@ module Samagotchi
609
577
  # A plugin's init task (chi.init): never a running turn's event.
610
578
  return announce(card.merge(in_turn: false)) && card[:id] if current_init_task
611
579
 
612
- sink = nil
613
- in_turn = @activity_mutex.synchronize do
614
- sink = @turn_event_sink
615
- @turn_running
616
- end
580
+ in_turn, sink = @turn_state.in_turn_sink
617
581
  card[:in_turn] = in_turn ? true : false
618
582
  if in_turn
619
583
  emit_event(sink, card)
@@ -819,7 +783,7 @@ module Samagotchi
819
783
  sync_model_key!
820
784
  @prompt_builder.reset!
821
785
  sync_kernel_client!
822
- @client.invalidate_context_window! if @client.respond_to?(:invalidate_context_window!)
786
+ @client.invalidate_context_window!
823
787
  @metrics.forget_model_reports!
824
788
  ref = effective_model_ref
825
789
  if persist_default
@@ -858,53 +822,17 @@ module Samagotchi
858
822
  end
859
823
 
860
824
  # ── 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
825
  #
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
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.
902
831
 
903
832
  # @return [Boolean] whether any reminder is due now (the store's view,
904
- # which #collect_due_reminders would inject), regardless of the REPL queue
833
+ # which a turn would inject), regardless of the queued names
905
834
  def reminders_due?
906
- due = @reminders&.due_reminders
907
- !(due.nil? || due.empty?)
835
+ @reminder_queue.due?
908
836
  end
909
837
 
910
838
  # @return [ReminderStore] the reminder store for inspection
@@ -941,9 +869,9 @@ module Samagotchi
941
869
  # no turn yet (it woke after an idle exit): nil when either count is
942
870
  # unknown or context.status is off.
943
871
  def saved_context_status(context)
944
- return nil unless context && @kernel.respond_to?(:context_display)
872
+ return nil unless context
945
873
 
946
- @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])
947
875
  end
948
876
 
949
877
  # The effective model is on a chat host (api: openai), whose loop uses
@@ -1008,7 +936,7 @@ module Samagotchi
1008
936
  event_seq: @session_observer&.event_count,
1009
937
  metrics: metrics,
1010
938
  pending_question: @question_desk.pending,
1011
- used_memory_names: @used_memory_mutex.synchronize { @used_memory_names.dup },
939
+ used_memory_names: @used_memories.names,
1012
940
  preloaded_memory_names: preloaded_memory_names,
1013
941
  muted_memory_names: @muted_memory_names.dup,
1014
942
  parent_id: @session&.parent_id,
@@ -1024,7 +952,7 @@ module Samagotchi
1024
952
 
1025
953
  # @return [Array<String>] deduped used memory names (thread-safe copy)
1026
954
  def used_memory_names
1027
- @used_memory_mutex.synchronize { @used_memory_names.dup }
955
+ @used_memories.names
1028
956
  end
1029
957
 
1030
958
  # @return [Array<String>] the memories hidden from this session (normalized names)
@@ -1043,73 +971,6 @@ module Samagotchi
1043
971
  MutedMemories.muted?(name, @muted_memory_names)
1044
972
  end
1045
973
 
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
974
  # ── Guardrails ─────────────────────────────────────────────────────────────
1114
975
 
1115
976
  # Who can answer an approval: :repl, :worker or :non_interactive (the
@@ -1132,7 +993,7 @@ module Samagotchi
1132
993
 
1133
994
  # The metrics load the session's saved records from its state dir, once.
1134
995
  def bind_metrics(session)
1135
- return unless session.respond_to?(:id) && session.id
996
+ return unless session&.id
1136
997
 
1137
998
  @metrics.state_dir = session_state_dir
1138
999
  @metrics.session_id = session.id
@@ -1222,6 +1083,38 @@ module Samagotchi
1222
1083
  end
1223
1084
  private :record_last_turn
1224
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
+
1225
1118
  # Keep what the after_turn hooks presented as the answer's `display`
1226
1119
  # and tell the observers (:answer_display), after the turn_completed
1227
1120
  # they already had: the web re-reads the answer then. A turn_completed
@@ -1275,11 +1168,7 @@ module Samagotchi
1275
1168
  notice = { type: :hook_notice, hook: hook.to_s, text: text.to_s, level: level }
1276
1169
  return hold_load_event(notice) && nil if @loading_plugins
1277
1170
 
1278
- sink = nil
1279
- in_turn = @activity_mutex.synchronize do
1280
- sink = @turn_event_sink
1281
- @turn_running
1282
- end
1171
+ in_turn, sink = @turn_state.in_turn_sink
1283
1172
  if anytime_thread?
1284
1173
  announce_anytime(notice.merge(between_turns: true))
1285
1174
  elsif current_init_task
@@ -1420,10 +1309,10 @@ module Samagotchi
1420
1309
  def session=(session)
1421
1310
  @session = session
1422
1311
  # One session per REPL/worker process: its records carry this sid.
1423
- Log.session_id = session.id if session.respond_to?(:id) && session.id
1312
+ Log.session_id = session.id if session&.id
1424
1313
  # A woken worker's /stats and status line count the turns before it.
1425
1314
  bind_metrics(session)
1426
- sync_used_memories_from_session(session)
1315
+ @used_memories.absorb(session)
1427
1316
  end
1428
1317
 
1429
1318
  # @return [IdleRecap, nil] the idle recap detector, or nil when disabled
@@ -1451,7 +1340,7 @@ module Samagotchi
1451
1340
  state = @recap&.state
1452
1341
  return nil unless state
1453
1342
 
1454
- messages = @activity_mutex.synchronize { @session&.messages } || []
1343
+ messages = @session&.messages || []
1455
1344
  covered = state[:covered].to_i
1456
1345
  turns_since = Array(messages).drop(covered).count { |m| Steer.turn_prompt?(m) }
1457
1346
  { text: state[:text], covered: covered, turns_since: turns_since, created_at: state[:created_at] }
@@ -1473,10 +1362,27 @@ module Samagotchi
1473
1362
  self
1474
1363
  end
1475
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
+
1476
1382
  # Run a single turn with event emission.
1477
1383
  #
1478
1384
  # Builds the system prompt + user messages, runs the kernel loop with
1479
- # event forwarding, and returns a KernelLoop::Result.
1385
+ # event forwarding, and returns its LLM::ModelResult.
1480
1386
  #
1481
1387
  # @param session [Session] the session to operate on
1482
1388
  # @param prompt [String] user input
@@ -1485,7 +1391,7 @@ module Samagotchi
1485
1391
  # @param cancel_controller [CancellationController, nil]
1486
1392
  # @param max_tool_output_chars [Integer, nil] per-output char cap for the
1487
1393
  # :tool_call_completed event's `output:` (nil → max_tool_output_chars)
1488
- # @return [KernelLoop::Result]
1394
+ # @return [LLM::ModelResult]
1489
1395
  # @param pending_input [#call, nil] optional drain proc returning
1490
1396
  # Array<String> of steering messages queued while the turn runs; drained
1491
1397
  # by the agentic loop at iteration boundaries (see KernelLoop#run).
@@ -1504,283 +1410,334 @@ module Samagotchi
1504
1410
  #
1505
1411
  # An Interrupt (SIGINT) cancels the turn: the pre-turn conversation plus
1506
1412
  # 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
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
1508
1416
  # other error (e.g. an LLM::ProviderError) emits :turn_failed and
1509
1417
  # re-raises; a provider error adds error_kind:, retryable:, host: and a
1510
1418
  # one-line summary:.
1511
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,
1512
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:)
1513
1458
  # Track the active session for recap and status snapshot.
1514
1459
  @session = session
1515
1460
  # status is turn state: running now, idle again before the turn's end
1516
1461
  # is announced, so a UI reacting to that event reads the new state.
1517
1462
  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)
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.
1523
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
+
1524
1497
  # Ask the server for its window again each turn (one short /props GET,
1525
1498
  # 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!)
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!
1528
1504
  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
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
1536
1523
  end
1537
- # The gate's context: who queued this turn, and git asked afresh.
1538
- @guardrail_wiring.begin_turn(origin)
1539
1524
 
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 }
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.
1548
1605
  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
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?
1585
1614
 
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
- })
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 }]
1608
1640
  end
1641
+ end
1609
1642
 
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
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
1616
1689
 
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
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
1651
1695
 
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)
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
1709
1700
 
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)
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
1719
1708
 
1720
- # Fire :session_end after every turn (turn-level lifecycle)
1721
- @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
1722
1713
 
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 = [] }
1777
- 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
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)
1782
1720
  end
1783
- 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
1784
1741
 
1785
1742
  # [refs, nil] for a turn's images, or [[], error] when one can't be used
1786
1743
  # (the turn then fails right after :turn_started).
@@ -1839,7 +1796,7 @@ module Samagotchi
1839
1796
  return unless target&.entry&.chat? && Thinking::EFFORTS.include?(level)
1840
1797
 
1841
1798
  client = target.client
1842
- 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)
1843
1800
  return unless Thinking.effort_ignored?(level, props)
1844
1801
 
1845
1802
  thinking_notice_once(:unsupported, target, :info,
@@ -1964,8 +1921,16 @@ module Samagotchi
1964
1921
 
1965
1922
  private
1966
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
+
1967
1932
  def replace_session_messages(session, messages)
1968
- @activity_mutex.synchronize { session.messages = messages }
1933
+ session.messages = messages
1969
1934
  end
1970
1935
 
1971
1936
  # Pick the loop for a target; the chat loop is pointed at the target
@@ -2081,11 +2046,9 @@ module Samagotchi
2081
2046
  ask_user: lambda { |question:, options:, header:, allow_freeform:, hook:|
2082
2047
  hook_ask_user(question, options, header, allow_freeform, hook)
2083
2048
  },
2084
- # Nothing to cancel while the Engine is still being built (a
2085
- # server that starts as its plugin loads).
2086
2049
  # An init task's is its own (a Ctrl-C ends a turn, not the task).
2087
2050
  cancelled: lambda {
2088
- (task = current_init_task) ? task.cancelled? : @activity_mutex && active_cancel_controller&.cancelled?
2051
+ (task = current_init_task) ? task.cancelled? : active_cancel_controller&.cancelled?
2089
2052
  },
2090
2053
  card: ->(**card) { show_card(**card) },
2091
2054
  steer: ->(text, label) { steer(text, source: hook_source(label)) },
@@ -2159,7 +2122,7 @@ module Samagotchi
2159
2122
  @auto_turn_callback = auto_turn_callback
2160
2123
  IdleReminders.new(
2161
2124
  engine: self,
2162
- reminder_store: @reminder_store,
2125
+ queue: @reminder_queue,
2163
2126
  callback: @auto_turn_callback
2164
2127
  )
2165
2128
  end
@@ -2221,7 +2184,7 @@ module Samagotchi
2221
2184
  def emit_event(on_event, event)
2222
2185
  # Capture used memories synchronously in the turn thread.
2223
2186
  read_names = begin
2224
- capture_used_memory_from_event(event)
2187
+ @used_memories.capture(event, muted: @muted_memory_names)
2225
2188
  rescue StandardError
2226
2189
  nil
2227
2190
  end
@@ -2253,7 +2216,7 @@ module Samagotchi
2253
2216
  adapter = nil
2254
2217
  if target.entry.chat?
2255
2218
  adapter = @host_registry.adapter_for(target.entry)
2256
- client = nil if adapter.respond_to?(:remote?) && adapter.remote?
2219
+ client = nil if adapter.remote?
2257
2220
  end
2258
2221
  ContextWindow.resolve(client: client, model: target.bare_model, adapter: adapter)
2259
2222
  rescue StandardError
@@ -2271,9 +2234,9 @@ module Samagotchi
2271
2234
  client = target.client
2272
2235
  if target.entry.chat?
2273
2236
  adapter = @host_registry.adapter_for(target.entry)
2274
- return [nil, nil] if adapter.respond_to?(:remote?) && adapter.remote?
2237
+ return [nil, nil] if adapter.remote?
2275
2238
  end
2276
- 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))
2277
2240
  served ? [served, asked] : [nil, nil]
2278
2241
  rescue StandardError
2279
2242
  [nil, nil]
@@ -2300,7 +2263,7 @@ module Samagotchi
2300
2263
  # prompt format and parser, and the system prompts built for the old one.
2301
2264
  def apply_profile(resolution)
2302
2265
  @prompt_builder&.reset! if @profile_resolution && @profile_resolution.profile.name != resolution.profile.name
2303
- @kernel.use_profile!(resolution) if @kernel.respond_to?(:use_profile!)
2266
+ @kernel.use_profile!(resolution)
2304
2267
  resolution
2305
2268
  end
2306
2269