samagotchi 0.109.0 → 0.111.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 (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/bin/chi +5 -1
  4. data/docs/architecture.md +9 -6
  5. data/docs/cli.md +43 -1
  6. data/docs/configuration.md +140 -3
  7. data/docs/internals/engine.md +58 -14
  8. data/docs/internals/participants.md +7 -4
  9. data/docs/internals/prompt-caching.md +5 -0
  10. data/docs/internals/remote-sessions.md +3 -3
  11. data/docs/internals/web-push.md +200 -0
  12. data/docs/internals/working-set.md +7 -6
  13. data/docs/plugins.md +1 -1
  14. data/docs/sessions.md +4 -2
  15. data/docs/sub-agent.md +1 -1
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/config.rb +4 -0
  19. data/lib/samagotchi/context_note.rb +7 -3
  20. data/lib/samagotchi/engine.rb +166 -499
  21. data/lib/samagotchi/engine_snapshots.rb +223 -0
  22. data/lib/samagotchi/event_fanout.rb +236 -0
  23. data/lib/samagotchi/idle_recap.rb +1 -1
  24. data/lib/samagotchi/idle_reminders.rb +1 -1
  25. data/lib/samagotchi/kernel_loop.rb +12 -7
  26. data/lib/samagotchi/llm/chat_loop.rb +9 -2
  27. data/lib/samagotchi/message_time.rb +104 -0
  28. data/lib/samagotchi/model_binding.rb +138 -0
  29. data/lib/samagotchi/reminder_queue.rb +20 -3
  30. data/lib/samagotchi/reply_wait/poll.rb +39 -1
  31. data/lib/samagotchi/run_options.rb +4 -1
  32. data/lib/samagotchi/send_command/wait_target.rb +10 -3
  33. data/lib/samagotchi/send_command.rb +2 -1
  34. data/lib/samagotchi/session.rb +12 -3
  35. data/lib/samagotchi/session_access/contract.rb +1 -1
  36. data/lib/samagotchi/session_access/local.rb +11 -5
  37. data/lib/samagotchi/session_access/remote.rb +2 -1
  38. data/lib/samagotchi/session_access.rb +7 -3
  39. data/lib/samagotchi/session_inbox.rb +7 -3
  40. data/lib/samagotchi/session_manager.rb +5 -0
  41. data/lib/samagotchi/steer.rb +5 -3
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
  43. data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
  44. data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
  45. data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
  46. data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
  47. data/lib/samagotchi/terminal_ui.rb +40 -5
  48. data/lib/samagotchi/tool_activity.rb +11 -0
  49. data/lib/samagotchi/turn_checkpoint.rb +6 -1
  50. data/lib/samagotchi/turn_endings.rb +137 -0
  51. data/lib/samagotchi/version.rb +1 -1
  52. data/lib/samagotchi/web/app.rb +28 -1369
  53. data/lib/samagotchi/web/grants.rb +3 -12
  54. data/lib/samagotchi/web/message_display.rb +274 -0
  55. data/lib/samagotchi/web/participant_view.rb +12 -5
  56. data/lib/samagotchi/web/private_files.rb +31 -0
  57. data/lib/samagotchi/web/public/app.js +41 -9
  58. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  59. data/lib/samagotchi/web/public/bubbles.js +29 -7
  60. data/lib/samagotchi/web/public/format.js +72 -6
  61. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  62. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  63. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  64. data/lib/samagotchi/web/public/index.html +6 -0
  65. data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
  66. data/lib/samagotchi/web/public/notifications.js +137 -3
  67. data/lib/samagotchi/web/public/notify.js +1 -1
  68. data/lib/samagotchi/web/public/participant.html +2 -0
  69. data/lib/samagotchi/web/public/participant.js +30 -7
  70. data/lib/samagotchi/web/public/participant_model.js +25 -2
  71. data/lib/samagotchi/web/public/push.js +95 -0
  72. data/lib/samagotchi/web/public/stage_view.js +8 -0
  73. data/lib/samagotchi/web/public/stream_handlers.js +32 -4
  74. data/lib/samagotchi/web/public/sw.js +101 -0
  75. data/lib/samagotchi/web/public/time_gaps.js +98 -0
  76. data/lib/samagotchi/web/public/tokens.css +2 -2
  77. data/lib/samagotchi/web/public/turn_events.js +25 -1
  78. data/lib/samagotchi/web/public/turn_html.js +15 -8
  79. data/lib/samagotchi/web/public/turn_view.js +8 -3
  80. data/lib/samagotchi/web/push/attention.rb +105 -0
  81. data/lib/samagotchi/web/push/client.rb +103 -0
  82. data/lib/samagotchi/web/push/crypto.rb +126 -0
  83. data/lib/samagotchi/web/push/notifier.rb +230 -0
  84. data/lib/samagotchi/web/push/store.rb +285 -0
  85. data/lib/samagotchi/web/push.rb +73 -0
  86. data/lib/samagotchi/web/push_routes.rb +99 -0
  87. data/lib/samagotchi/web/running.rb +39 -4
  88. data/lib/samagotchi/web/server.rb +112 -32
  89. data/lib/samagotchi/web/session_control.rb +441 -0
  90. data/lib/samagotchi/web/session_create.rb +384 -0
  91. data/lib/samagotchi/web/session_show.rb +411 -0
  92. data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
  93. data/lib/samagotchi/web/tunnel.rb +133 -0
  94. data/lib/samagotchi/worker.rb +3 -2
  95. metadata +30 -1
@@ -13,6 +13,7 @@ require_relative "context_note"
13
13
  require_relative "context_status"
14
14
  require_relative "steer"
15
15
  require_relative "turn_note"
16
+ require_relative "turn_endings"
16
17
  require_relative "turn_origin"
17
18
  require_relative "model_profile"
18
19
  require_relative "effective_model"
@@ -53,6 +54,7 @@ require_relative "recap_setup"
53
54
  require_relative "idle_reminders"
54
55
  require_relative "idle_scheduler"
55
56
  require_relative "hooks"
57
+ require_relative "event_fanout"
56
58
  require_relative "event_routes"
57
59
  require_relative "hook_routes"
58
60
  require_relative "guardrails"
@@ -65,7 +67,9 @@ require_relative "used_memories"
65
67
  require_relative "turn_state"
66
68
  require_relative "steer_cut"
67
69
  require_relative "engine_peers"
70
+ require_relative "engine_snapshots"
68
71
  require_relative "bundle_needs"
72
+ require_relative "model_binding"
69
73
  require_relative "model_overlay"
70
74
  require_relative "model_notes"
71
75
  require_relative "prompt_note"
@@ -78,6 +82,7 @@ require_relative "thinking"
78
82
  require_relative "reasoning_history"
79
83
  require_relative "answer_display"
80
84
  require_relative "answer_refs"
85
+ require_relative "message_time"
81
86
  require_relative "edit_preview"
82
87
  require_relative "question_desk"
83
88
  require_relative "question_asked"
@@ -141,6 +146,12 @@ module Samagotchi
141
146
  # A message for the running turn cutting its generation.
142
147
  @steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller },
143
148
  cut_after: -> { steer_cut_after })
149
+ # How an event reaches the turn's sink and the observers, and the
150
+ # stream handler. Built early: the question desk's and the init tasks'
151
+ # emit go through it.
152
+ @event_fanout = build_event_fanout
153
+ # What each turn ending keeps and announces (one lookup).
154
+ @turn_endings = build_turn_endings
144
155
  @scratch = scratch
145
156
  @no_interrupt = no_interrupt
146
157
  @chat_backend = nil
@@ -151,22 +162,22 @@ module Samagotchi
151
162
  # is used (the REPL checks the model it starts on, #switch_model!).
152
163
  @default_model_name = ModelProfile.required_model_name(model_name)
153
164
  ModelProfile.check_host!(model_name, hosts: @host_registry.entries) unless model_name.to_s.strip.empty?
154
- # Guards the model choice and the profile cached for it (#store_profile).
155
- @profile_mutex = Mutex.new
165
+ # The model choice and the profile resolved for it, under one mutex.
166
+ # A caller's profile pins it (until a model switch); otherwise
167
+ # #profile_resolution decides on first need (see there), so building an
168
+ # Engine makes no network call.
156
169
  typed = model_typed.to_s.strip
157
- @model_choice = ModelChoice.new(name: @default_model_name, typed: typed.empty? ? @default_model_name : typed)
170
+ @model_binding = build_model_binding(
171
+ ModelChoice.new(name: @default_model_name, typed: typed.empty? ? @default_model_name : typed),
172
+ profile ? ModelProfile.normalize(profile) : nil
173
+ )
158
174
  # The session's settings on its effective model (SessionModelSettings),
159
175
  # read at use: /model moves the choice, #session= the session.
160
- @model_settings = SessionModelSettings.new(registry: @host_registry, choice: -> { @model_choice },
176
+ @model_settings = SessionModelSettings.new(registry: @host_registry, choice: -> { @model_binding.choice },
161
177
  session: -> { @session })
162
178
  # An injected client (specs) stands in for every host's client.
163
179
  @host_registry.client_override = client if client
164
180
  @client = effective_model.client
165
- # A caller's profile pins it (until a model switch); otherwise
166
- # #profile_resolution decides on first need (see there), so building an
167
- # Engine makes no network call.
168
- @given_profile = profile ? ModelProfile.normalize(profile) : nil
169
- @profile_resolution = nil
170
181
  # Ensure the built-in system bundle is installed (lazy, warn-only).
171
182
  # This is the single seam for both TUI and non-TUI (web/worker) paths.
172
183
  begin
@@ -253,6 +264,9 @@ module Samagotchi
253
264
  # the single background thread and isolates per-job failures.
254
265
  @idle_scheduler = build_idle_scheduler
255
266
  subscribe_session_observers
267
+ # The UIs' read model (/state, /stats). Built last: nothing calls a
268
+ # snapshot while the Engine is built.
269
+ @engine_snapshots = build_engine_snapshots
256
270
  end
257
271
 
258
272
  # ── initialize's collaborators ──────────────────────────────────────────
@@ -337,7 +351,7 @@ module Samagotchi
337
351
  def wire_kernel(kernel, muted_memories)
338
352
  # A given kernel (specs) gets the Engine's store, hooks and tools, as
339
353
  # the one built here does.
340
- @kernel = kernel || KernelLoop.new(client: @client, profile: @given_profile, model_name: effective_model_name,
354
+ @kernel = kernel || KernelLoop.new(client: @client, profile: @model_binding.given_profile, model_name: effective_model_name,
341
355
  hooks: @hooks, reminder_store: @reminder_store, tools: @tools)
342
356
  @kernel.reminder_store = @reminder_store
343
357
  @kernel.hooks = @hooks
@@ -382,7 +396,7 @@ module Samagotchi
382
396
  hooks: -> { @hooks },
383
397
  tools: -> { @tools },
384
398
  session: -> { @session },
385
- model_key: -> { @model_key },
399
+ model_key: -> { @model_binding.model_key },
386
400
  model_name: -> { guardrail_model_name },
387
401
  cancelled: -> { !!active_cancel_controller&.cancelled? },
388
402
  # An approval of the model's call: :question_asked first (QuestionAsked).
@@ -392,6 +406,74 @@ module Samagotchi
392
406
  end
393
407
  private :build_guardrail_wiring
394
408
 
409
+ # The event fan-out and the stream handler (see #initialize for when).
410
+ def build_event_fanout
411
+ EventFanout.new(
412
+ observer: -> { @session_observer },
413
+ used_memories: -> { @used_memories },
414
+ muted: -> { @muted_memory_names },
415
+ steer_cut: -> { @steer_cut },
416
+ thinking_notices: -> { @thinking_notices },
417
+ metrics: -> { @metrics },
418
+ hooks: -> { @hooks },
419
+ kernel: -> { @kernel },
420
+ model_settings: -> { @model_settings },
421
+ session: -> { @session },
422
+ live_conversation: -> { live_conversation },
423
+ used_memory_names: -> { used_memory_names },
424
+ served_expected_by: ->(served) { served_expected_by(served) },
425
+ working_set_strategy: -> { working_set_strategy },
426
+ turn_running: -> { turn_running? },
427
+ announce_or_hold: ->(event) { announce_or_hold(event) }
428
+ )
429
+ end
430
+ private :build_event_fanout
431
+
432
+ # The snapshots the UIs read (see #initialize for when).
433
+ def build_engine_snapshots
434
+ EngineSnapshots.new(
435
+ session: -> { @session },
436
+ metrics: -> { @metrics },
437
+ observer: -> { @session_observer },
438
+ question_desk: -> { @question_desk },
439
+ used_memories: -> { @used_memories },
440
+ muted: -> { @muted_memory_names },
441
+ prompt_builder: -> { @prompt_builder },
442
+ participants: -> { @participants },
443
+ participants_watch: -> { @participants_watch },
444
+ recap: -> { @recap },
445
+ last_context_status: -> { @last_context_status },
446
+ model_settings: -> { @model_settings },
447
+ effective_model: -> { effective_model },
448
+ effective_model_name: -> { effective_model_name },
449
+ effective_model_ref: -> { effective_model_ref },
450
+ bare_model_name: ->(ref) { bare_model_name(ref) },
451
+ served_model: ->(probe:) { served_model(probe: probe) },
452
+ prompt_notes: -> { prompt_notes },
453
+ preloaded: -> { preloaded_memory_names },
454
+ saved_context_status: ->(context, **opts) { saved_context_status(context, **opts) },
455
+ context_window: ->(model) { current_context_window(model) },
456
+ profile_resolution: -> { profile_resolution },
457
+ toolkit_summary: -> { toolkit_summary },
458
+ toolkit_explained: -> { toolkit_explained }
459
+ )
460
+ end
461
+ private :build_engine_snapshots
462
+
463
+ # The model choice and its prompt profile (see #initialize for when).
464
+ def build_model_binding(choice, given_profile)
465
+ ModelBinding.new(choice: choice, given_profile: given_profile, registry: @host_registry,
466
+ kernel: -> { @kernel }, prompt_builder: -> { @prompt_builder },
467
+ bare_name: ->(name) { bare_model_name(name) })
468
+ end
469
+ private :build_model_binding
470
+
471
+ # The turn endings' builders (see #initialize for when).
472
+ def build_turn_endings
473
+ TurnEndings.new(failure_summary: ->(error) { failure_summary(error) })
474
+ end
475
+ private :build_turn_endings
476
+
395
477
  # The question flow (see #initialize for when).
396
478
  def build_question_desk
397
479
  QuestionDesk.new(
@@ -585,9 +667,12 @@ module Samagotchi
585
667
 
586
668
  # Emit a :recap_ready event (additive slot) carrying the generated recap
587
669
  # and the generation id an observer uses to reject an invalidated recap.
588
- # +covered+ counts the session messages it summarizes.
589
- def emit_recap(recap:, generation:, covered: nil)
590
- @session_observer.notify(type: :recap_ready, recap: recap, generation: generation, covered: covered)
670
+ # +covered+ counts the session messages it summarizes; +created_at+ is
671
+ # the saved recap's (UTC ISO; the UIs show its local time).
672
+ def emit_recap(recap:, generation:, covered: nil, created_at: nil)
673
+ event = { type: :recap_ready, recap: recap, generation: generation, covered: covered }
674
+ event[:created_at] = created_at if created_at
675
+ @session_observer.notify(event)
591
676
  end
592
677
 
593
678
  # Count one side request (a recap, a plugin's ctx.ask_model: IdleClient's
@@ -639,10 +724,10 @@ module Samagotchi
639
724
  attr_reader :metrics
640
725
 
641
726
  # The model as given (maybe an alias; HostRegistry#resolve applies it).
642
- def effective_model_name = @model_choice.name
727
+ def effective_model_name = @model_binding.choice.name
643
728
 
644
729
  # The model as typed at start or at the last /model.
645
- def typed_model_name = @model_choice.typed
730
+ def typed_model_name = @model_binding.choice.typed
646
731
 
647
732
  # The resolved ref a session stores for the effective model (ModelRef#ref:
648
733
  # the alias applied, "host:id" when it names a host).
@@ -658,15 +743,15 @@ module Samagotchi
658
743
  model = effective_model
659
744
  entry = model.entry
660
745
  host = entry && "host #{entry.name}, #{entry.url || "#{entry.host}:#{entry.port}"}"
661
- SystemPrompt::ModelIdentity.new(ref: model.ref, host: host, key: @model_key,
662
- served: model.prompt_served, fallback_key: @fallback_model_key)
746
+ SystemPrompt::ModelIdentity.new(ref: model.ref, host: host, key: @model_binding.model_key,
747
+ served: model.prompt_served, fallback_key: @model_binding.fallback_model_key)
663
748
  end
664
749
 
665
750
  # The model notes for the effective model (ModelNotes): matched on its
666
751
  # bare id and key, the session's mutes left out.
667
752
  # @return [Array<ModelNotes::Note>]
668
753
  def model_notes
669
- ModelNotes.for(name: guardrail_model_name, key: @model_key, fallback_key: @fallback_model_key,
754
+ ModelNotes.for(name: guardrail_model_name, key: @model_binding.model_key, fallback_key: @model_binding.fallback_model_key,
670
755
  muted: @muted_memory_names)
671
756
  end
672
757
 
@@ -729,28 +814,17 @@ module Samagotchi
729
814
  # The effective model's key (ModelOverlay.key_for its bare name): memory
730
815
  # overlays, guardrail rules' models:.
731
816
  # @return [String, nil]
732
- attr_reader :model_key
817
+ def model_key = @model_binding.model_key
733
818
 
734
819
  # The prompt profile for the effective model (see #profile_resolution).
735
820
  # @return [ModelProfile]
736
821
  def profile = profile_resolution.profile
737
822
 
738
- # Which profile the effective model gets and where that came from
739
- # (ModelProfile.resolve: --profile/env, models:, hosts.<name>.profile,
740
- # the server's chat template, the name, qwen36). Resolved on first need
741
- # and kept, so the system prompt and the server's KV prefix stay stable;
742
- # #switch_model! starts over, and a failed server probe is retried before
743
- # the next turn (#refresh_profile!). The kernel follows each resolution.
744
- # Kept with the model choice it was resolved for: one that finishes
745
- # after a switch is neither kept nor applied (#store_profile).
823
+ # Which profile the effective model gets and where that came from,
824
+ # kept with the model choice it was resolved for
825
+ # (ModelBinding#profile_resolution).
746
826
  # @return [ModelProfile::Resolution]
747
- def profile_resolution
748
- choice = @model_choice
749
- cached = @profile_resolution
750
- return cached.last if cached&.first.equal?(choice)
751
-
752
- store_profile(choice, resolve_profile(choice))
753
- end
827
+ def profile_resolution = @model_binding.profile_resolution
754
828
  attr_reader :default_model_name, :host_registry, :client
755
829
 
756
830
  # @return [Commands::Registry] the commands this session runs: the
@@ -765,17 +839,8 @@ module Samagotchi
765
839
  @host_registry.bare_name(full_ref)
766
840
  end
767
841
 
768
- # The memory overlay key follows the model sent (ModelOverlay.key_for
769
- # its id). A model typed as an alias used to key overlays by the alias
770
- # (before aliases were applied there): that key is read when the
771
- # target's overlay is missing (KernelLoop#sync_model_key!).
772
- def sync_model_key!
773
- choice = @model_choice
774
- @model_key = ModelOverlay.key_for(bare_model_name(choice.name))
775
- typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(choice.typed).last)
776
- @fallback_model_key = typed_key == @model_key ? nil : typed_key
777
- @kernel.sync_model_key!(@model_key, fallback: @fallback_model_key)
778
- end
842
+ # The memory overlay keys follow the model sent (ModelBinding#sync_model_key!).
843
+ def sync_model_key! = @model_binding.sync_model_key!
779
844
  private :sync_model_key!
780
845
 
781
846
  # The effective model's bare name for the guardrails' models: rules;
@@ -1085,13 +1150,7 @@ module Samagotchi
1085
1150
  # once; checked here: an unknown host, an alias naming another host.
1086
1151
  resolved = ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries)
1087
1152
  choice = ModelChoice.new(name: resolved, typed: typed.to_s.strip.empty? ? model_name : typed.to_s.strip)
1088
- @profile_mutex.synchronize do
1089
- # One assignment: a reader on another thread sees the old pair or the new one.
1090
- @model_choice = choice
1091
- # A profile given to .new was for the starting model.
1092
- @given_profile = nil
1093
- @profile_resolution = nil
1094
- end
1153
+ @model_binding.switch!(choice)
1095
1154
  sync_model_key!
1096
1155
  @prompt_builder.reset!
1097
1156
  # The notes the new model's prompt loads at its next build, so /model,
@@ -1192,53 +1251,10 @@ module Samagotchi
1192
1251
  # @return [ReminderStore] the reminder store for inspection
1193
1252
  attr_reader :reminder_store
1194
1253
 
1195
- # The metrics for /stats. Before the first generation reports them, the
1196
- # context window, the served model and (on a native host) the prompt
1197
- # profile come from the effective model's server, so this may make one
1198
- # short /props GET; the cheap #session_state_snapshot never does.
1199
- # price_host: the effective model's host, whose hosts.<name>.models
1200
- # prices the cost estimate names. kit_prompt: the next turn's kit
1201
- # prompt text ({kit:, tokens:}), only when its toolkit has one.
1202
- # settings: the session's own settings ({"recap.enabled" => false}),
1203
- # only when it has some.
1254
+ # The metrics for /stats, which may make one short /props GET
1255
+ # (EngineSnapshots#stats_snapshot).
1204
1256
  # @return [Hash] @metrics.snapshot, filled in
1205
- def stats_snapshot
1206
- snapshot = @metrics.snapshot
1207
- # One resolve for the whole snapshot (its readers' rescues stay theirs);
1208
- # a model that doesn't resolve raises here, as it always has.
1209
- model = effective_model
1210
- served, served_for = served_model_for(snapshot, model: model)
1211
- snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
1212
- served_expected_by: model.served_expected_by(served),
1213
- **setting_chips(SessionSetting::STATS, model),
1214
- prompt_notes: prompt_notes.map(&:to_h), prompt_files: @prompt_builder.prompt_files,
1215
- price_host: model.host_name)
1216
- kit_prompt = kit_prompt_stats
1217
- snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
1218
- # Each participant's spend under their name (participants.json's).
1219
- if (spend = snapshot[:spend_by_author])
1220
- named = spend.to_h { |author, row| [author, row.merge(name: @participants.name_for(author, session: @session))] }
1221
- snapshot = snapshot.merge(spend_by_author: named)
1222
- end
1223
- # The session's own settings (chi --set), as --set names them.
1224
- settings = @session&.settings&.flat || {}
1225
- snapshot = snapshot.merge(settings: settings) unless settings.empty?
1226
- unless snapshot.dig(:context, :window_tokens)
1227
- window = current_context_window(model)
1228
- if window
1229
- snapshot = snapshot.merge(context: snapshot[:context].merge(window_tokens: window.tokens,
1230
- window_source: window.source.to_s))
1231
- end
1232
- end
1233
- # The chat loop uses no prompt profile: drop one a native turn reported.
1234
- return snapshot.except(:profile, :profile_source) if model.chat?
1235
-
1236
- unless snapshot[:profile]
1237
- resolution = profile_resolution
1238
- snapshot = snapshot.merge(profile: resolution.profile.name, profile_source: resolution.label)
1239
- end
1240
- snapshot
1241
- end
1257
+ def stats_snapshot = @engine_snapshots.stats_snapshot
1242
1258
 
1243
1259
  # ── The session's model settings (SessionModelSettings) ──────────────────
1244
1260
 
@@ -1309,67 +1325,14 @@ module Samagotchi
1309
1325
  # else [nil, nil].
1310
1326
  # @return [Array(String, String), Array(nil, nil)]
1311
1327
  def served_model(probe: true)
1312
- served_model_for(@metrics.served_report, model: probe ? effective_model : nil)
1328
+ @engine_snapshots.served_model_for(@metrics.served_report, model: probe ? effective_model : nil)
1313
1329
  end
1314
1330
 
1315
1331
  # Read-only snapshot of the engine's view of the current session plus the
1316
- # live event sequence. Cheap primitive used by the bridge's reconnect-too-
1317
- # old reset marker and the GET /session/:id/state read surface. Orthogonal
1318
- # to the transport — safe to call before the first turn (nil session).
1319
- #
1320
- # @return [Hash] with keys:
1321
- # :status [String, nil] current session status
1322
- # :message_count [Integer] number of messages in the session
1323
- # :last_prompt [String, nil] the last user prompt (empty string if none)
1324
- # :event_seq [Integer] @session_observer.event_count
1325
- # :metrics [Hash] @metrics.snapshot (per-session analytics)
1326
- # :pending_question [Hash, nil] the pending question (PendingQuestion#to_h)
1327
- # :used_memory_names [Array<String>] deduped memory names active this session
1328
- # :prompt_notes [Array<Hash>] the model notes the prompt carried
1329
- # ({name:, scope:, chars:, digest:}; #prompt_notes)
1330
- # :parent_id [String, nil] the session that delegated this one
1331
- # :model_name [String] the model turns run on now (after /model)
1332
- # :served_model, :served_model_for [String, nil] what a generation of
1333
- # that model reported serving, and the name asked (#served_model
1334
- # without the probe)
1335
- # :served_expected_by [String, nil] the host whose models: entry names
1336
- # that served model under served: (#served_expected_by)
1337
- # :recap_enabled [Boolean] whether an idle recap is configured, with
1338
- # :recap_min_user_turns and :recap_inactivity_seconds (nil when not)
1339
- # :participants [Array<Hash>] who is in the review now ({id:, name:,
1340
- # kind:}; ParticipantsWatch#active), [] for nobody
1341
- def session_state_snapshot
1342
- served_pair = served_model(probe: false)
1343
- metrics = @metrics.snapshot
1344
- # One resolve for the model's fields; nil when it doesn't resolve (the
1345
- # Bridge's /state and reset frames rely on this never raising): those
1346
- # fields are nil then, as their own rescues always gave.
1347
- model = @model_settings.model_setting(nil) { |resolved| resolved }
1348
- {
1349
- status: @session&.status,
1350
- message_count: (@session&.messages || []).size,
1351
- last_prompt: @session&.last_prompt,
1352
- event_seq: @session_observer&.event_count,
1353
- metrics: metrics,
1354
- pending_question: @question_desk.pending&.to_h,
1355
- used_memory_names: @used_memories.names,
1356
- preloaded_memory_names: preloaded_memory_names,
1357
- muted_memory_names: @muted_memory_names.dup,
1358
- prompt_notes: prompt_notes.map(&:to_h),
1359
- parent_id: @session&.parent_id,
1360
- title: @session&.title,
1361
- model_name: effective_model_ref,
1362
- served_model: served_pair[0],
1363
- served_model_for: served_pair[1],
1364
- served_expected_by: model&.served_expected_by(served_pair[0]),
1365
- context_status: @last_context_status&.dup || saved_context_status(metrics[:context], model: model),
1366
- **setting_chips(SessionSetting::ALL, model),
1367
- recap_enabled: !@recap.nil?,
1368
- recap_min_user_turns: @recap&.min_user_turns,
1369
- recap_inactivity_seconds: @recap&.inactivity&.to_i,
1370
- participants: @participants_watch.active
1371
- }
1372
- end
1332
+ # live event sequence; cheap, never probes, never raises
1333
+ # (EngineSnapshots#session_state_snapshot has its keys).
1334
+ # @return [Hash]
1335
+ def session_state_snapshot = @engine_snapshots.session_state_snapshot
1373
1336
 
1374
1337
  # @return [Array<String>] deduped used memory names (thread-safe copy)
1375
1338
  def used_memory_names
@@ -1547,20 +1510,8 @@ module Samagotchi
1547
1510
  Steer.author_fields(author, @participants.name_for(author, session: session))
1548
1511
  end
1549
1512
 
1550
- # Why a turn stopped, from its end event (LastTurn::STOP_FACTS), for a
1551
- # parent agent's wait (ReplyWait): a provider error's kind and whether
1552
- # a retry may help, a cancel's reason and the hook that stopped it.
1553
- # Only what is known.
1554
- def stop_facts(event)
1555
- case event&.dig(:type)
1556
- # kept_steps: the turn's work stayed (a parent must not send the task again).
1557
- when :turn_failed
1558
- { error_kind: event[:error_kind]&.to_s, retryable: event[:retryable], kept_steps: event[:kept_steps] }
1559
- when :turn_canceled
1560
- { cancel_reason: event[:cancellation_reason]&.to_s, stopped_by: event[:cancelled_by]&.to_s }
1561
- else {}
1562
- end
1563
- end
1513
+ # Why a turn stopped (TurnEndings#stop_facts).
1514
+ def stop_facts(event) = @turn_endings.stop_facts(event)
1564
1515
  private :stop_facts
1565
1516
 
1566
1517
  # How every turn ends (completed, canceled, failed): the session gets
@@ -1997,8 +1948,10 @@ module Samagotchi
1997
1948
  # +resume_from+: where its reasoning starts, the run it resumes
1998
1949
  # (run_turn's resume_from:); nil for every other turn, and for a
1999
1950
  # Continue that starts cold (after a model change).
1951
+ # +wall+: the wall time it started (MessageTime.now, once): the
1952
+ # :turn_started event's at and its user message's (and reminders') stamp.
2000
1953
  Turn = Struct.new(:session, :prompt, :continue, :on_event, :controller, :origin, :started_at, :messages, :ended,
2001
- :settings, :id, :limit, :resumed, :resume_from) do
1954
+ :settings, :id, :limit, :resumed, :resume_from, :wall) do
2002
1955
  def resumed? = resumed ? true : false
2003
1956
 
2004
1957
  # The turn's boundary events carry the origin only when it names a
@@ -2009,6 +1962,9 @@ module Samagotchi
2009
1962
 
2010
1963
  # Seconds since the turn started (the cancel note says how long it ran).
2011
1964
  def elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
1965
+
1966
+ # The wall time's stamp text (MessageTime::KEY).
1967
+ def at = MessageTime.iso(wall)
2012
1968
  end
2013
1969
  private_constant :Turn
2014
1970
 
@@ -2141,7 +2097,7 @@ module Samagotchi
2141
2097
 
2142
2098
  Turn.new(session: session, prompt: continue ? nil : prompt, continue: continue, on_event: on_event,
2143
2099
  controller: effective_controller, origin: origin, started_at: Process.clock_gettime(Process::CLOCK_MONOTONIC),
2144
- id: id || SecureRandom.uuid)
2100
+ id: id || SecureRandom.uuid, wall: MessageTime.now)
2145
2101
  end
2146
2102
  private :begin_turn
2147
2103
 
@@ -2155,7 +2111,7 @@ module Samagotchi
2155
2111
  image_refs, image_error = turn_image_refs(session, images)
2156
2112
  # Emit turn_started event
2157
2113
  bind_metrics(session)
2158
- turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id }
2114
+ turn_started = { type: :turn_started, session_id: session.id, prompt: turn.prompt, turn_id: turn.id, at: turn.at }
2159
2115
  turn_started[:continue] = true if turn.continue
2160
2116
  turn_started[:resumed] = true if turn.resumed?
2161
2117
  turn_started[:images] = image_refs unless image_refs.empty?
@@ -2316,16 +2272,18 @@ module Samagotchi
2316
2272
  # system prompt invalidates cache for the entire prefix.
2317
2273
  # Not into a participant's turn: the owner's reminders are the owner's,
2318
2274
  # and would be used up there under the review boundary.
2319
- due_reminders = turn.origin.participant? ? [] : @reminder_queue.inject!(turn.messages)
2275
+ due_reminders = turn.origin.participant? ? [] : @reminder_queue.inject!(turn.messages, now: turn.wall)
2320
2276
  if due_reminders.any?
2321
2277
  emit_event(turn.on_event, {
2322
2278
  type: :reminder_injected,
2323
- reminders: due_reminders
2279
+ reminders: due_reminders,
2280
+ at: turn.at
2324
2281
  })
2325
2282
  end
2326
2283
 
2327
2284
  unless turn.continue
2328
- user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author) }
2285
+ user_message = { role: "user", content: turn.prompt, turn_id: turn.id, **author_fields(turn.origin.author),
2286
+ MessageTime::KEY => turn.at }
2329
2287
  user_message[:images] = image_refs unless image_refs.empty?
2330
2288
  turn.messages << user_message
2331
2289
  session.last_prompt = turn.prompt
@@ -2475,89 +2433,17 @@ module Samagotchi
2475
2433
  end
2476
2434
  private :complete_turn
2477
2435
 
2478
- # What the session keeps of a turn that ended normally (nil: nothing to
2479
- # replace). Runs under the event lock: it also adds the turn's note to
2480
- # result.conversation, which the REPL keeps as-is.
2481
- # A turn that ran out of iterations ends at its tool results, so a
2482
- # continue resumes from them rather than after a made-up reply (and a
2483
- # step-limit Continue from its run's start: ModelResult#run_start).
2484
- def kept_messages(turn, result, seconds)
2485
- conversation = result.conversation if result.conversation.is_a?(Array)
2486
- # Nothing visible (no text) is a turn the model should know ended
2487
- # that way: the note, and nothing made up as its answer. The note's
2488
- # marker is for the UIs (the notice, and the empty steps after a
2489
- # reload); it goes on the result's conversation too (the REPL keeps it).
2490
- # A retry's nudge at the tail goes: this note says it all. An earlier
2491
- # turn's note stays: it says why that turn ended.
2492
- if result.empty_answer?
2493
- note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps, refusal: result.refusal)
2494
- conversation&.replace(TurnNote.without_trailing_nudge(conversation))
2495
- saved = TurnNote.without_trailing_nudge(conversation || turn.session.messages)
2496
- conversation << note if conversation
2497
- saved + [note]
2498
- elsif result.canceled? && conversation
2499
- conversation << TurnNote.cancelled(result.cancellation_reason, seconds: seconds,
2500
- stopped_by: turn.controller.detail,
2501
- shown: TurnNote.interrupted_tail?(conversation),
2502
- running_tasks: Tools::TaskRuntime.running_created_in(conversation))
2503
- else
2504
- conversation
2505
- end
2506
- end
2436
+ # What each turn ending keeps and announces is TurnEndings'
2437
+ # (#build_turn_endings); these forward to it.
2438
+ def kept_messages(turn, result, seconds) = @turn_endings.kept_messages(turn, result, seconds)
2507
2439
  private :kept_messages
2508
-
2509
- # A Ctrl-C keeps the loop's conversation so far, as a Stop does (its
2510
- # finished tool iterations, every call answered: ToolResponse.run_batch),
2511
- # else the turn's messages so far (it came before the loop), with a
2512
- # cancel note naming the tasks still running; nil before there are any.
2513
- def ctrl_c_messages(turn, interrupt, seconds)
2514
- kept = loop_conversation(interrupt) || turn.messages
2515
- return nil unless kept
2516
-
2517
- note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(kept))
2518
- TurnNote.replace_trailing(kept, note)
2519
- end
2440
+ def ctrl_c_messages(turn, interrupt, seconds) = @turn_endings.ctrl_c_messages(turn, interrupt, seconds)
2520
2441
  private :ctrl_c_messages
2521
-
2522
- # The conversation the loop had built when +error+ ended the turn
2523
- # (LLM::FailedTurn), or nil.
2524
- def loop_conversation(error)
2525
- error.partial_conversation if error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array)
2526
- end
2442
+ def loop_conversation(error) = @turn_endings.loop_conversation(error)
2527
2443
  private :loop_conversation
2528
-
2529
- # A failure keeps the loop's partial conversation (else the turn's
2530
- # messages so far) with a failed note; nil when the turn never reached
2531
- # the model. The model reads why on its next turn. A turn that got to
2532
- # tool results (LLM::FailedTurn.progress) says so in the note; on the
2533
- # error (kept_steps) too unless it failed in a way that rolls back
2534
- # (FailedTurn.keeps_work?: a context overflow). The UIs keep a marked
2535
- # turn (TurnFlow#prompt_turn_failed), and roll back the others, with a
2536
- # note of their own; -p --non-interactive keeps what this keeps.
2537
- def failed_messages(turn, error)
2538
- kept = loop_conversation(error) || turn.messages
2539
- return nil unless kept
2540
-
2541
- steps = LLM::FailedTurn.progress(turn.session.messages, kept)
2542
- error.kept_steps = steps if steps && error.is_a?(LLM::FailedTurn) && LLM::FailedTurn.keeps_work?(error)
2543
- summary = error.respond_to?(:summary) ? error.summary : error.message
2544
- TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: turn.continue, steps: steps))
2545
- end
2444
+ def failed_messages(turn, error) = @turn_endings.failed_messages(turn, error)
2546
2445
  private :failed_messages
2547
-
2548
- # Called after #failed_messages, which marks the error's kept steps.
2549
- def failed_event(error, seconds)
2550
- failed = { type: :turn_failed, error_class: error.class.name, message: error.message,
2551
- duration_ms: (seconds * 1000).round }
2552
- # Its work stays: the UIs say so, as after a cancel.
2553
- steps = LLM::FailedTurn.kept_steps(error)
2554
- failed[:kept_steps] = steps if steps
2555
- # A provider error says what kind it is, for one line per kind in the UIs.
2556
- if error.is_a?(LLM::ProviderError)
2557
- failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: failure_summary(error))
2558
- end
2559
- failed
2560
- end
2446
+ def failed_event(error, seconds) = @turn_endings.failed_event(error, seconds)
2561
2447
  private :failed_event
2562
2448
 
2563
2449
  # However the turn ended (#run_turn's ensure): the turn flag off, the
@@ -2615,23 +2501,10 @@ module Samagotchi
2615
2501
  raise LLM::VisionUnsupported.new("#{host}: #{vision.refusal_reason}", host: host)
2616
2502
  end
2617
2503
 
2618
- # JSON-safe digest of a finished turn for renderers (in-process or over the
2619
- # Bridge): the bits of the native loop's result a UI needs beyond `result:`.
2504
+ # JSON-safe digest of a finished turn for renderers (TurnEndings#turn_summary).
2620
2505
  # @param result [LLM::ModelResult]
2621
2506
  # @return [Hash]
2622
- def turn_summary(result)
2623
- summary = {
2624
- output: result.output.to_s,
2625
- exhausted: result.exhausted?,
2626
- resumable: result.resumable?,
2627
- pending_tool_calls: result.pending_tool_calls?,
2628
- tool_activity: Array(result.tool_activity).map(&:dup),
2629
- context_status: result.context_status&.dup
2630
- }
2631
- # No answer: every UI shows its notice (TurnNote.empty_answer_line).
2632
- summary[:empty_answer] = { retries: result.empty_retries.to_i, refusal: result.refusal }.compact if result.empty_answer?
2633
- summary
2634
- end
2507
+ def turn_summary(result) = @turn_endings.turn_summary(result)
2635
2508
 
2636
2509
  # ── Session messages API ───────────────────────────────────────────────
2637
2510
  #
@@ -2882,7 +2755,7 @@ module Samagotchi
2882
2755
  model: model, source: source)
2883
2756
  },
2884
2757
  model_name: -> { effective_model_ref },
2885
- model_key: -> { @model_key },
2758
+ model_key: -> { @model_binding.model_key },
2886
2759
  state_dir: -> { session_state_dir },
2887
2760
  scratch: -> { @scratch },
2888
2761
  setup: -> { SessionSetup.of(@session) },
@@ -2999,253 +2872,47 @@ module Samagotchi
2999
2872
 
3000
2873
  # ── Event helpers ──────────────────────────────────────────────────────────
3001
2874
 
3002
- # Always return a handler so raw kernel loop events reach the persistent
3003
- # SessionObserver (and thus the analytics collector) even when there is no
3004
- # turn-scoped +on_event+ sink (e.g. the -p/--resume paths and
3005
- # SessionManager background workers). emit_event tolerates a nil on_event by
3006
- # only notifying the observer.
3007
- # Wrap a raw kernel stream event and fan it out to the turn sink + observers.
3008
- #
3009
- # Each :generation_chunk comes split already, by the loop that streamed it:
3010
- # besides the raw `content` (left untouched — the TUI thinking spinner,
3011
- # analytics, and Bridge replay rely on it) it carries
3012
- # * :text — visible prose (thinking AND tool_call blocks removed)
3013
- # * :thinking — thinking-only content
3014
- # KernelLoop splits a native stream with a ThoughtStreamSplitter per
3015
- # generation (every profile, Gemma's thought channel included); the chat
3016
- # loop gets reasoning apart from the answer. The web routes on those
3017
- # fields (chunk_router.js).
3018
- #
3019
- # With a :generation_progress hook registered, a Hooks::StreamWatch gets
3020
- # each chunk's thinking and text after the UIs had it.
3021
- #
3022
- # +model_ref+: the turn's model (#generation_model_ref), added to each
3023
- # :generation_started as `model_ref:` (both loops send through here).
3024
- def build_stream_event_handler(on_event, cancel_controller: nil, model_ref: nil)
3025
- watch = stream_watch(cancel_controller)
3026
- proc do |event|
3027
- progress = SteerCut.lanes(event) if event[:type] == :generation_chunk
3028
- @steer_cut.observe(event, progress)
3029
- case event[:type]
3030
- when :generation_started
3031
- event = event.merge(model_ref: model_ref) if model_ref
3032
- @thinking_notices.generation_started
3033
- watch&.started(event[:iteration])
3034
- when :generation_retrying
3035
- # A dropped stream's step asked again: it streams from the start.
3036
- if event[:restarted]
3037
- @thinking_notices.generation_started
3038
- watch&.started(event[:iteration], restarted: true)
3039
- end
3040
- when :generation_chunk then @thinking_notices.note_thought(progress[:thinking])
3041
- end
3042
- # The chat loop asked again without the thinking fields or the
3043
- # reasoning sent back: a notice, not an event of its own.
3044
- next @thinking_notices.thinking_refused(event) if event[:type] == :thinking_refused
3045
- next @thinking_notices.reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
3046
- next @thinking_notices.sampling_refused(event) if event[:type] == :sampling_refused
3047
-
3048
- event = with_generation_report(event) if event[:type] == :generation_completed
3049
- emit_event(on_event, event)
3050
- # What this request sends: both loops start a generation after
3051
- # their apply_llm_context!.
3052
- emit_working_set(on_event, @session, live_conversation) if event[:type] == :generation_started
3053
- watch.feed(**progress) if watch && progress
3054
- @thinking_notices.generation_completed(event) if event[:type] == :generation_completed
3055
- end
3056
- end
3057
-
3058
- # The working set (WorkingSet) as the model has it, as a
3059
- # :working_set_updated event ({items:, at:}, adds: while the user's
3060
- # adds are pending): at each request (the
3061
- # stream handler's :generation_started, from the running turn's
3062
- # conversation) and at turn end (#end_turn, from the session's kept
3063
- # messages). Only when it differs from the last one emitted for this
3064
- # session, and never while it is empty — except one empty event,
3065
- # remembered, when the last one for this session had items (a
3066
- # rollback removed the tool calls), so a tool-free turn emits none
3067
- # and the next empty request sends nothing more. The web overlays it
3068
- # on the rows' set; other UIs ignore it.
3069
- #
3070
- # Between turns (#announce_working_set: no +on_event+) it is
3071
- # announced, under the next turn's strategy.
3072
- def emit_working_set(on_event, session, conversation)
3073
- return unless session && conversation
3074
-
3075
- snapshot = WorkingSet.build(conversation, cwd: session.working_directory, strategy: working_set_strategy,
3076
- pins: session.working_set_pins)
3077
-
3078
- shown = snapshot.to_h.except(:at)
3079
- last = @working_set_emitted
3080
- if snapshot.empty?
3081
- return unless last && last[0] == session.id && last[1] != { items: [] }
3082
- elsif last == [session.id, shown]
3083
- return
3084
- end
3085
-
3086
- @working_set_emitted = [session.id, shown]
3087
- event = { type: :working_set_updated, **shown, at: snapshot.at }
3088
- on_event ? emit_event(on_event, event) : announce_or_hold(event)
3089
- rescue StandardError => e
3090
- Log.warn(:turn, "working_set_failed", error: e.class.name, msg: e.message.to_s[0, 200])
3091
- end
2875
+ # The stream handler, the working set and the event fan-out are
2876
+ # EventFanout's (#build_event_fanout); these forward to it.
2877
+ def build_stream_event_handler(...) = @event_fanout.build_stream_event_handler(...)
2878
+ def emit_working_set(...) = @event_fanout.emit_working_set(...)
3092
2879
  private :emit_working_set
3093
-
3094
- # The strategy the set is built under: the running turn's
3095
- # (TurnSettings#llm_context: what its requests send); between turns
3096
- # the next turn's (as #warm_up_next_turn: the turn's settings keep the
3097
- # last turn's, stale after a /llm-context or /model change), the last
3098
- # turn's when it can't be resolved.
3099
- def working_set_llm_context
3100
- turn = @kernel.respond_to?(:turn_settings) ? @kernel.turn_settings&.llm_context : nil
3101
- return turn if turn_running?
3102
-
3103
- @model_settings.turn_llm_context || turn
3104
- end
2880
+ def working_set_llm_context = @event_fanout.working_set_llm_context
3105
2881
  private :working_set_llm_context
3106
-
3107
- # A :generation_completed with the generation's decode speed (`speed:`
3108
- # {decode_tps:, source:}, nil without one), the session's running
3109
- # token totals (`tokens:`, the snapshot's block) and its per-model rows
3110
- # (`model_segments:`, the snapshot's) and whether its served
3111
- # model is an expected one (`served_expected_by:`, its host): the web's info bar
3112
- # reads them from the stream. SessionMetrics closes the generation here,
3113
- # so the event it gets next finds it closed.
3114
- def with_generation_report(event)
3115
- report = @metrics.finish_generation
3116
- event.merge(speed: report.speed&.to_h, tokens: report.tokens, model_segments: report.model_segments,
3117
- served_expected_by: served_expected_by(event[:served_model]))
3118
- rescue StandardError => e
3119
- Log.warn(:turn, "generation_report_failed", error: e.class.name, msg: e.message.to_s[0, 200])
3120
- event
3121
- end
2882
+ def with_generation_report(event) = @event_fanout.with_generation_report(event)
3122
2883
  private :with_generation_report
3123
-
3124
- # The turn's StreamWatch, or nil when no hook listens.
3125
- def stream_watch(cancel_controller)
3126
- return nil unless cancel_controller && @hooks.any?(Hooks::StreamWatch::EVENT)
3127
-
3128
- Hooks::StreamWatch.new(hooks: @hooks, cancel_controller: cancel_controller)
3129
- end
2884
+ def stream_watch(cancel_controller) = @event_fanout.stream_watch(cancel_controller)
3130
2885
  private :stream_watch
3131
-
3132
- def emit_event(on_event, event)
3133
- # Capture used memories synchronously in the turn thread.
3134
- read_names = begin
3135
- @used_memories.capture(event, muted: @muted_memory_names)
3136
- rescue StandardError => e
3137
- Log.warn(:memory, "used_memories_capture_failed", error: e.class.name, msg: e.message.to_s[0, 200])
3138
- nil
3139
- end
3140
- # Turn-scoped sink: receives the event hash as the stream handler
3141
- # passed it (no event_seq; a :generation_completed has its report). Sink errors are isolated and never break the
3142
- # kernel loop (same as KernelLoop's own handling).
3143
- if on_event
3144
- begin
3145
- on_event.call(event)
3146
- rescue StandardError => e
3147
- # Sink errors must not break the kernel loop (same as KernelLoop's own handling)
3148
- Log.warn(:turn, "sink_failed", event_type: event[:type].to_s, error: e.class.name, msg: e.message.to_s[0, 200])
3149
- end
3150
- end
3151
-
3152
- # Persistent subscribers: receive a copy with a locally-monotonic
3153
- # `event_seq`, fan out with per-subscriber error isolation.
3154
- @session_observer.notify(event)
3155
- # Every memory read, after its tool_call_started: the whole list for
3156
- # the UIs' memory line, and the names this call read (read_names).
3157
- return unless read_names
3158
-
3159
- emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names, read_names: read_names })
3160
- end
2886
+ def emit_event(on_event, event) = @event_fanout.emit_event(on_event, event)
3161
2887
 
3162
2888
  # The effective model's window_tokens and price, read per turn
3163
2889
  # (SessionModelSettings).
3164
2890
  def turn_window_setting = @model_settings.turn_window_setting
3165
2891
  def turn_price = @model_settings.turn_price
3166
2892
 
3167
- # See #served_model. A report for another name (before a /model switch)
3168
- # doesn't count. Without a model (EffectiveModel), no probe. A remote
3169
- # chat host has no /props: nil until a turn. The name asked is never
3170
- # resolved (HostRegistry#bare_name), so it holds on a broken resolve.
3171
- def served_model_for(snapshot, model: nil)
3172
- asked = bare_model_name(model ? model.name : effective_model_name)
3173
- return [snapshot[:served_model], asked] if snapshot[:served_model] && snapshot[:served_model_for] == asked
3174
- return [nil, nil] unless model
3175
-
3176
- model.served_from_props(asked)
3177
- # Only bare_model_name and the snapshot lookup are left to fail here:
3178
- # the probe's failure is EffectiveModel#served_from_props's.
3179
- rescue StandardError
3180
- [nil, nil]
3181
- end
3182
-
3183
- # The next turn's kit prompt text for /stats: its kit and its estimated
3184
- # tokens (chars / 4), nil when its toolkit has none.
3185
- def kit_prompt_stats
3186
- resolved = toolkit_explained.resolved
3187
- return nil unless resolved.prompt
3188
-
3189
- { kit: resolved.name, tokens: (resolved.prompt.length / TokenUsage::CHARS_PER_TOKEN).ceil }
3190
- end
2893
+ # The snapshots' helpers are EngineSnapshots' (#build_engine_snapshots);
2894
+ # these forward to it.
2895
+ def served_model_for(...) = @engine_snapshots.served_model_for(...)
2896
+ def kit_prompt_stats = @engine_snapshots.kit_prompt_stats
3191
2897
 
3192
2898
  # The window as +model+'s loop would see it (EffectiveModel#context_window).
3193
2899
  def current_context_window(model) = model.context_window
3194
2900
 
3195
- # +settings+' chips (SessionSetting), the model's from +model+ (nil
3196
- # when it doesn't resolve): /stats, the session state, a :command_ran.
3197
- def setting_chips(settings, model)
3198
- settings.to_h { |setting| [setting.key, setting.model? ? setting.chip_for(model, @session) : toolkit_summary] }
3199
- end
2901
+ # +settings+' chips (EngineSnapshots#setting_chips).
2902
+ def setting_chips(settings, model) = @engine_snapshots.setting_chips(settings, model)
3200
2903
 
3201
2904
  # The effective model now, resolved on each call, never kept
3202
2905
  # (SessionModelSettings#effective_model). Raises as HostRegistry#resolve does.
3203
2906
  # @return [EffectiveModel]
3204
- def effective_model(choice = @model_choice) = @model_settings.effective_model(choice)
2907
+ def effective_model(choice = @model_binding.choice) = @model_settings.effective_model(choice)
3205
2908
 
3206
2909
  # ── Prompt profile ─────────────────────────────────────────────────────────
3207
2910
 
3208
- # +choice+'s profile (ModelProfile.resolve; may probe /props), or the
3209
- # one given to .new.
3210
- def resolve_profile(choice)
3211
- given = @given_profile
3212
- return ModelProfile::Resolution.new(profile: given, source: :given, detail: nil, retry: false) if given
3213
-
3214
- EffectiveModel.of(@host_registry, choice).profile_resolution
3215
- end
3216
-
3217
- # Keeps +resolution+ as +choice+'s and applies it, unless the model
3218
- # switched while it was resolved (a /stats on a Bridge thread probing
3219
- # as a /model ran): then it is only returned, and the new model's is
3220
- # resolved on its first need.
3221
- # @return [ModelProfile::Resolution]
3222
- def store_profile(choice, resolution)
3223
- @profile_mutex.synchronize do
3224
- return resolution unless @model_choice.equal?(choice)
3225
-
3226
- apply_profile(resolution)
3227
- @profile_resolution = [choice, resolution]
3228
- end
3229
- resolution
3230
- end
3231
-
3232
- # Everything that holds a profile follows the resolution: the kernel's
3233
- # prompt format and parser, and the system prompts built for the old one.
3234
- def apply_profile(resolution)
3235
- kept = @profile_resolution&.last
3236
- @prompt_builder&.reset! if kept && kept.profile.name != resolution.profile.name
3237
- @kernel.use_profile!(resolution)
3238
- resolution
3239
- end
3240
-
3241
- # Before a turn: resolve now if nothing has yet, or again if the last
3242
- # server probe failed (unreachable, or 503 while loading a model). A
3243
- # profile that changes here drops the cached system prompts.
3244
- def refresh_profile!
3245
- choice, kept = @profile_resolution
3246
- return profile_resolution unless choice.equal?(@model_choice) && kept.retry?
3247
-
3248
- store_profile(choice, resolve_profile(choice))
3249
- end
2911
+ # The profile's resolve, keep, apply and retry are ModelBinding's
2912
+ # (#build_model_binding); these forward to it.
2913
+ def resolve_profile(choice) = @model_binding.resolve_profile(choice)
2914
+ def store_profile(choice, resolution) = @model_binding.store_profile(choice, resolution)
2915
+ def apply_profile(resolution) = @model_binding.apply_profile(resolution)
2916
+ def refresh_profile! = @model_binding.refresh_profile!
3250
2917
  end
3251
2918
  end