samagotchi 0.110.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -1
  3. data/docs/architecture.md +8 -5
  4. data/docs/cli.md +3 -0
  5. data/docs/configuration.md +43 -0
  6. data/docs/internals/engine.md +39 -10
  7. data/docs/internals/participants.md +1 -1
  8. data/docs/internals/remote-sessions.md +3 -3
  9. data/docs/internals/web-push.md +200 -0
  10. data/docs/internals/working-set.md +7 -6
  11. data/docs/sessions.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/config.rb +4 -0
  14. data/lib/samagotchi/engine.rb +146 -490
  15. data/lib/samagotchi/engine_snapshots.rb +223 -0
  16. data/lib/samagotchi/event_fanout.rb +236 -0
  17. data/lib/samagotchi/model_binding.rb +138 -0
  18. data/lib/samagotchi/send_command/wait_target.rb +10 -3
  19. data/lib/samagotchi/send_command.rb +2 -1
  20. data/lib/samagotchi/session.rb +12 -3
  21. data/lib/samagotchi/session_access/remote.rb +2 -1
  22. data/lib/samagotchi/session_access.rb +7 -3
  23. data/lib/samagotchi/session_manager.rb +5 -0
  24. data/lib/samagotchi/turn_endings.rb +137 -0
  25. data/lib/samagotchi/version.rb +1 -1
  26. data/lib/samagotchi/web/app.rb +27 -1377
  27. data/lib/samagotchi/web/grants.rb +3 -12
  28. data/lib/samagotchi/web/message_display.rb +274 -0
  29. data/lib/samagotchi/web/private_files.rb +31 -0
  30. data/lib/samagotchi/web/public/app.js +30 -6
  31. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  32. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  33. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  34. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  35. data/lib/samagotchi/web/public/index.html +4 -0
  36. data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
  37. data/lib/samagotchi/web/public/notifications.js +137 -3
  38. data/lib/samagotchi/web/public/notify.js +1 -1
  39. data/lib/samagotchi/web/public/push.js +95 -0
  40. data/lib/samagotchi/web/public/stage_view.js +8 -0
  41. data/lib/samagotchi/web/public/sw.js +101 -0
  42. data/lib/samagotchi/web/public/turn_events.js +22 -0
  43. data/lib/samagotchi/web/push/attention.rb +105 -0
  44. data/lib/samagotchi/web/push/client.rb +103 -0
  45. data/lib/samagotchi/web/push/crypto.rb +126 -0
  46. data/lib/samagotchi/web/push/notifier.rb +230 -0
  47. data/lib/samagotchi/web/push/store.rb +285 -0
  48. data/lib/samagotchi/web/push.rb +73 -0
  49. data/lib/samagotchi/web/push_routes.rb +99 -0
  50. data/lib/samagotchi/web/server.rb +26 -2
  51. data/lib/samagotchi/web/session_control.rb +441 -0
  52. data/lib/samagotchi/web/session_create.rb +384 -0
  53. data/lib/samagotchi/web/session_show.rb +411 -0
  54. data/lib/samagotchi/worker.rb +3 -2
  55. metadata +25 -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"
@@ -142,6 +146,12 @@ module Samagotchi
142
146
  # A message for the running turn cutting its generation.
143
147
  @steer_cut = SteerCut.new(clock: -> { monotonic_now }, controller: -> { active_cancel_controller },
144
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
145
155
  @scratch = scratch
146
156
  @no_interrupt = no_interrupt
147
157
  @chat_backend = nil
@@ -152,22 +162,22 @@ module Samagotchi
152
162
  # is used (the REPL checks the model it starts on, #switch_model!).
153
163
  @default_model_name = ModelProfile.required_model_name(model_name)
154
164
  ModelProfile.check_host!(model_name, hosts: @host_registry.entries) unless model_name.to_s.strip.empty?
155
- # Guards the model choice and the profile cached for it (#store_profile).
156
- @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.
157
169
  typed = model_typed.to_s.strip
158
- @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
+ )
159
174
  # The session's settings on its effective model (SessionModelSettings),
160
175
  # read at use: /model moves the choice, #session= the session.
161
- @model_settings = SessionModelSettings.new(registry: @host_registry, choice: -> { @model_choice },
176
+ @model_settings = SessionModelSettings.new(registry: @host_registry, choice: -> { @model_binding.choice },
162
177
  session: -> { @session })
163
178
  # An injected client (specs) stands in for every host's client.
164
179
  @host_registry.client_override = client if client
165
180
  @client = effective_model.client
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.
169
- @given_profile = profile ? ModelProfile.normalize(profile) : nil
170
- @profile_resolution = nil
171
181
  # Ensure the built-in system bundle is installed (lazy, warn-only).
172
182
  # This is the single seam for both TUI and non-TUI (web/worker) paths.
173
183
  begin
@@ -254,6 +264,9 @@ module Samagotchi
254
264
  # the single background thread and isolates per-job failures.
255
265
  @idle_scheduler = build_idle_scheduler
256
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
257
270
  end
258
271
 
259
272
  # ── initialize's collaborators ──────────────────────────────────────────
@@ -338,7 +351,7 @@ module Samagotchi
338
351
  def wire_kernel(kernel, muted_memories)
339
352
  # A given kernel (specs) gets the Engine's store, hooks and tools, as
340
353
  # the one built here does.
341
- @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,
342
355
  hooks: @hooks, reminder_store: @reminder_store, tools: @tools)
343
356
  @kernel.reminder_store = @reminder_store
344
357
  @kernel.hooks = @hooks
@@ -383,7 +396,7 @@ module Samagotchi
383
396
  hooks: -> { @hooks },
384
397
  tools: -> { @tools },
385
398
  session: -> { @session },
386
- model_key: -> { @model_key },
399
+ model_key: -> { @model_binding.model_key },
387
400
  model_name: -> { guardrail_model_name },
388
401
  cancelled: -> { !!active_cancel_controller&.cancelled? },
389
402
  # An approval of the model's call: :question_asked first (QuestionAsked).
@@ -393,6 +406,74 @@ module Samagotchi
393
406
  end
394
407
  private :build_guardrail_wiring
395
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
+
396
477
  # The question flow (see #initialize for when).
397
478
  def build_question_desk
398
479
  QuestionDesk.new(
@@ -643,10 +724,10 @@ module Samagotchi
643
724
  attr_reader :metrics
644
725
 
645
726
  # The model as given (maybe an alias; HostRegistry#resolve applies it).
646
- def effective_model_name = @model_choice.name
727
+ def effective_model_name = @model_binding.choice.name
647
728
 
648
729
  # The model as typed at start or at the last /model.
649
- def typed_model_name = @model_choice.typed
730
+ def typed_model_name = @model_binding.choice.typed
650
731
 
651
732
  # The resolved ref a session stores for the effective model (ModelRef#ref:
652
733
  # the alias applied, "host:id" when it names a host).
@@ -662,15 +743,15 @@ module Samagotchi
662
743
  model = effective_model
663
744
  entry = model.entry
664
745
  host = entry && "host #{entry.name}, #{entry.url || "#{entry.host}:#{entry.port}"}"
665
- SystemPrompt::ModelIdentity.new(ref: model.ref, host: host, key: @model_key,
666
- 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)
667
748
  end
668
749
 
669
750
  # The model notes for the effective model (ModelNotes): matched on its
670
751
  # bare id and key, the session's mutes left out.
671
752
  # @return [Array<ModelNotes::Note>]
672
753
  def model_notes
673
- 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,
674
755
  muted: @muted_memory_names)
675
756
  end
676
757
 
@@ -733,28 +814,17 @@ module Samagotchi
733
814
  # The effective model's key (ModelOverlay.key_for its bare name): memory
734
815
  # overlays, guardrail rules' models:.
735
816
  # @return [String, nil]
736
- attr_reader :model_key
817
+ def model_key = @model_binding.model_key
737
818
 
738
819
  # The prompt profile for the effective model (see #profile_resolution).
739
820
  # @return [ModelProfile]
740
821
  def profile = profile_resolution.profile
741
822
 
742
- # Which profile the effective model gets and where that came from
743
- # (ModelProfile.resolve: --profile/env, models:, hosts.<name>.profile,
744
- # the server's chat template, the name, qwen36). Resolved on first need
745
- # and kept, so the system prompt and the server's KV prefix stay stable;
746
- # #switch_model! starts over, and a failed server probe is retried before
747
- # the next turn (#refresh_profile!). The kernel follows each resolution.
748
- # Kept with the model choice it was resolved for: one that finishes
749
- # 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).
750
826
  # @return [ModelProfile::Resolution]
751
- def profile_resolution
752
- choice = @model_choice
753
- cached = @profile_resolution
754
- return cached.last if cached&.first.equal?(choice)
755
-
756
- store_profile(choice, resolve_profile(choice))
757
- end
827
+ def profile_resolution = @model_binding.profile_resolution
758
828
  attr_reader :default_model_name, :host_registry, :client
759
829
 
760
830
  # @return [Commands::Registry] the commands this session runs: the
@@ -769,17 +839,8 @@ module Samagotchi
769
839
  @host_registry.bare_name(full_ref)
770
840
  end
771
841
 
772
- # The memory overlay key follows the model sent (ModelOverlay.key_for
773
- # its id). A model typed as an alias used to key overlays by the alias
774
- # (before aliases were applied there): that key is read when the
775
- # target's overlay is missing (KernelLoop#sync_model_key!).
776
- def sync_model_key!
777
- choice = @model_choice
778
- @model_key = ModelOverlay.key_for(bare_model_name(choice.name))
779
- typed_key = ModelOverlay.key_for(@host_registry.parse_qualified_model(choice.typed).last)
780
- @fallback_model_key = typed_key == @model_key ? nil : typed_key
781
- @kernel.sync_model_key!(@model_key, fallback: @fallback_model_key)
782
- end
842
+ # The memory overlay keys follow the model sent (ModelBinding#sync_model_key!).
843
+ def sync_model_key! = @model_binding.sync_model_key!
783
844
  private :sync_model_key!
784
845
 
785
846
  # The effective model's bare name for the guardrails' models: rules;
@@ -1089,13 +1150,7 @@ module Samagotchi
1089
1150
  # once; checked here: an unknown host, an alias naming another host.
1090
1151
  resolved = ModelProfile.check_host!(ModelProfile.required_model_name(model_name), hosts: @host_registry.entries)
1091
1152
  choice = ModelChoice.new(name: resolved, typed: typed.to_s.strip.empty? ? model_name : typed.to_s.strip)
1092
- @profile_mutex.synchronize do
1093
- # One assignment: a reader on another thread sees the old pair or the new one.
1094
- @model_choice = choice
1095
- # A profile given to .new was for the starting model.
1096
- @given_profile = nil
1097
- @profile_resolution = nil
1098
- end
1153
+ @model_binding.switch!(choice)
1099
1154
  sync_model_key!
1100
1155
  @prompt_builder.reset!
1101
1156
  # The notes the new model's prompt loads at its next build, so /model,
@@ -1196,53 +1251,10 @@ module Samagotchi
1196
1251
  # @return [ReminderStore] the reminder store for inspection
1197
1252
  attr_reader :reminder_store
1198
1253
 
1199
- # The metrics for /stats. Before the first generation reports them, the
1200
- # context window, the served model and (on a native host) the prompt
1201
- # profile come from the effective model's server, so this may make one
1202
- # short /props GET; the cheap #session_state_snapshot never does.
1203
- # price_host: the effective model's host, whose hosts.<name>.models
1204
- # prices the cost estimate names. kit_prompt: the next turn's kit
1205
- # prompt text ({kit:, tokens:}), only when its toolkit has one.
1206
- # settings: the session's own settings ({"recap.enabled" => false}),
1207
- # only when it has some.
1254
+ # The metrics for /stats, which may make one short /props GET
1255
+ # (EngineSnapshots#stats_snapshot).
1208
1256
  # @return [Hash] @metrics.snapshot, filled in
1209
- def stats_snapshot
1210
- snapshot = @metrics.snapshot
1211
- # One resolve for the whole snapshot (its readers' rescues stay theirs);
1212
- # a model that doesn't resolve raises here, as it always has.
1213
- model = effective_model
1214
- served, served_for = served_model_for(snapshot, model: model)
1215
- snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
1216
- served_expected_by: model.served_expected_by(served),
1217
- **setting_chips(SessionSetting::STATS, model),
1218
- prompt_notes: prompt_notes.map(&:to_h), prompt_files: @prompt_builder.prompt_files,
1219
- price_host: model.host_name)
1220
- kit_prompt = kit_prompt_stats
1221
- snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
1222
- # Each participant's spend under their name (participants.json's).
1223
- if (spend = snapshot[:spend_by_author])
1224
- named = spend.to_h { |author, row| [author, row.merge(name: @participants.name_for(author, session: @session))] }
1225
- snapshot = snapshot.merge(spend_by_author: named)
1226
- end
1227
- # The session's own settings (chi --set), as --set names them.
1228
- settings = @session&.settings&.flat || {}
1229
- snapshot = snapshot.merge(settings: settings) unless settings.empty?
1230
- unless snapshot.dig(:context, :window_tokens)
1231
- window = current_context_window(model)
1232
- if window
1233
- snapshot = snapshot.merge(context: snapshot[:context].merge(window_tokens: window.tokens,
1234
- window_source: window.source.to_s))
1235
- end
1236
- end
1237
- # The chat loop uses no prompt profile: drop one a native turn reported.
1238
- return snapshot.except(:profile, :profile_source) if model.chat?
1239
-
1240
- unless snapshot[:profile]
1241
- resolution = profile_resolution
1242
- snapshot = snapshot.merge(profile: resolution.profile.name, profile_source: resolution.label)
1243
- end
1244
- snapshot
1245
- end
1257
+ def stats_snapshot = @engine_snapshots.stats_snapshot
1246
1258
 
1247
1259
  # ── The session's model settings (SessionModelSettings) ──────────────────
1248
1260
 
@@ -1313,67 +1325,14 @@ module Samagotchi
1313
1325
  # else [nil, nil].
1314
1326
  # @return [Array(String, String), Array(nil, nil)]
1315
1327
  def served_model(probe: true)
1316
- 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)
1317
1329
  end
1318
1330
 
1319
1331
  # Read-only snapshot of the engine's view of the current session plus the
1320
- # live event sequence. Cheap primitive used by the bridge's reconnect-too-
1321
- # old reset marker and the GET /session/:id/state read surface. Orthogonal
1322
- # to the transport — safe to call before the first turn (nil session).
1323
- #
1324
- # @return [Hash] with keys:
1325
- # :status [String, nil] current session status
1326
- # :message_count [Integer] number of messages in the session
1327
- # :last_prompt [String, nil] the last user prompt (empty string if none)
1328
- # :event_seq [Integer] @session_observer.event_count
1329
- # :metrics [Hash] @metrics.snapshot (per-session analytics)
1330
- # :pending_question [Hash, nil] the pending question (PendingQuestion#to_h)
1331
- # :used_memory_names [Array<String>] deduped memory names active this session
1332
- # :prompt_notes [Array<Hash>] the model notes the prompt carried
1333
- # ({name:, scope:, chars:, digest:}; #prompt_notes)
1334
- # :parent_id [String, nil] the session that delegated this one
1335
- # :model_name [String] the model turns run on now (after /model)
1336
- # :served_model, :served_model_for [String, nil] what a generation of
1337
- # that model reported serving, and the name asked (#served_model
1338
- # without the probe)
1339
- # :served_expected_by [String, nil] the host whose models: entry names
1340
- # that served model under served: (#served_expected_by)
1341
- # :recap_enabled [Boolean] whether an idle recap is configured, with
1342
- # :recap_min_user_turns and :recap_inactivity_seconds (nil when not)
1343
- # :participants [Array<Hash>] who is in the review now ({id:, name:,
1344
- # kind:}; ParticipantsWatch#active), [] for nobody
1345
- def session_state_snapshot
1346
- served_pair = served_model(probe: false)
1347
- metrics = @metrics.snapshot
1348
- # One resolve for the model's fields; nil when it doesn't resolve (the
1349
- # Bridge's /state and reset frames rely on this never raising): those
1350
- # fields are nil then, as their own rescues always gave.
1351
- model = @model_settings.model_setting(nil) { |resolved| resolved }
1352
- {
1353
- status: @session&.status,
1354
- message_count: (@session&.messages || []).size,
1355
- last_prompt: @session&.last_prompt,
1356
- event_seq: @session_observer&.event_count,
1357
- metrics: metrics,
1358
- pending_question: @question_desk.pending&.to_h,
1359
- used_memory_names: @used_memories.names,
1360
- preloaded_memory_names: preloaded_memory_names,
1361
- muted_memory_names: @muted_memory_names.dup,
1362
- prompt_notes: prompt_notes.map(&:to_h),
1363
- parent_id: @session&.parent_id,
1364
- title: @session&.title,
1365
- model_name: effective_model_ref,
1366
- served_model: served_pair[0],
1367
- served_model_for: served_pair[1],
1368
- served_expected_by: model&.served_expected_by(served_pair[0]),
1369
- context_status: @last_context_status&.dup || saved_context_status(metrics[:context], model: model),
1370
- **setting_chips(SessionSetting::ALL, model),
1371
- recap_enabled: !@recap.nil?,
1372
- recap_min_user_turns: @recap&.min_user_turns,
1373
- recap_inactivity_seconds: @recap&.inactivity&.to_i,
1374
- participants: @participants_watch.active
1375
- }
1376
- 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
1377
1336
 
1378
1337
  # @return [Array<String>] deduped used memory names (thread-safe copy)
1379
1338
  def used_memory_names
@@ -1551,20 +1510,8 @@ module Samagotchi
1551
1510
  Steer.author_fields(author, @participants.name_for(author, session: session))
1552
1511
  end
1553
1512
 
1554
- # Why a turn stopped, from its end event (LastTurn::STOP_FACTS), for a
1555
- # parent agent's wait (ReplyWait): a provider error's kind and whether
1556
- # a retry may help, a cancel's reason and the hook that stopped it.
1557
- # Only what is known.
1558
- def stop_facts(event)
1559
- case event&.dig(:type)
1560
- # kept_steps: the turn's work stayed (a parent must not send the task again).
1561
- when :turn_failed
1562
- { error_kind: event[:error_kind]&.to_s, retryable: event[:retryable], kept_steps: event[:kept_steps] }
1563
- when :turn_canceled
1564
- { cancel_reason: event[:cancellation_reason]&.to_s, stopped_by: event[:cancelled_by]&.to_s }
1565
- else {}
1566
- end
1567
- end
1513
+ # Why a turn stopped (TurnEndings#stop_facts).
1514
+ def stop_facts(event) = @turn_endings.stop_facts(event)
1568
1515
  private :stop_facts
1569
1516
 
1570
1517
  # How every turn ends (completed, canceled, failed): the session gets
@@ -2486,89 +2433,17 @@ module Samagotchi
2486
2433
  end
2487
2434
  private :complete_turn
2488
2435
 
2489
- # What the session keeps of a turn that ended normally (nil: nothing to
2490
- # replace). Runs under the event lock: it also adds the turn's note to
2491
- # result.conversation, which the REPL keeps as-is.
2492
- # A turn that ran out of iterations ends at its tool results, so a
2493
- # continue resumes from them rather than after a made-up reply (and a
2494
- # step-limit Continue from its run's start: ModelResult#run_start).
2495
- def kept_messages(turn, result, seconds)
2496
- conversation = result.conversation if result.conversation.is_a?(Array)
2497
- # Nothing visible (no text) is a turn the model should know ended
2498
- # that way: the note, and nothing made up as its answer. The note's
2499
- # marker is for the UIs (the notice, and the empty steps after a
2500
- # reload); it goes on the result's conversation too (the REPL keeps it).
2501
- # A retry's nudge at the tail goes: this note says it all. An earlier
2502
- # turn's note stays: it says why that turn ended.
2503
- if result.empty_answer?
2504
- note = TurnNote.empty(retries: result.empty_retries, steps: result.empty_steps, refusal: result.refusal)
2505
- conversation&.replace(TurnNote.without_trailing_nudge(conversation))
2506
- saved = TurnNote.without_trailing_nudge(conversation || turn.session.messages)
2507
- conversation << note if conversation
2508
- saved + [note]
2509
- elsif result.canceled? && conversation
2510
- conversation << TurnNote.cancelled(result.cancellation_reason, seconds: seconds,
2511
- stopped_by: turn.controller.detail,
2512
- shown: TurnNote.interrupted_tail?(conversation),
2513
- running_tasks: Tools::TaskRuntime.running_created_in(conversation))
2514
- else
2515
- conversation
2516
- end
2517
- 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)
2518
2439
  private :kept_messages
2519
-
2520
- # A Ctrl-C keeps the loop's conversation so far, as a Stop does (its
2521
- # finished tool iterations, every call answered: ToolResponse.run_batch),
2522
- # else the turn's messages so far (it came before the loop), with a
2523
- # cancel note naming the tasks still running; nil before there are any.
2524
- def ctrl_c_messages(turn, interrupt, seconds)
2525
- kept = loop_conversation(interrupt) || turn.messages
2526
- return nil unless kept
2527
-
2528
- note = TurnNote.cancelled(:ctrl_c, seconds: seconds, running_tasks: Tools::TaskRuntime.running_created_in(kept))
2529
- TurnNote.replace_trailing(kept, note)
2530
- end
2440
+ def ctrl_c_messages(turn, interrupt, seconds) = @turn_endings.ctrl_c_messages(turn, interrupt, seconds)
2531
2441
  private :ctrl_c_messages
2532
-
2533
- # The conversation the loop had built when +error+ ended the turn
2534
- # (LLM::FailedTurn), or nil.
2535
- def loop_conversation(error)
2536
- error.partial_conversation if error.respond_to?(:partial_conversation) && error.partial_conversation.is_a?(Array)
2537
- end
2442
+ def loop_conversation(error) = @turn_endings.loop_conversation(error)
2538
2443
  private :loop_conversation
2539
-
2540
- # A failure keeps the loop's partial conversation (else the turn's
2541
- # messages so far) with a failed note; nil when the turn never reached
2542
- # the model. The model reads why on its next turn. A turn that got to
2543
- # tool results (LLM::FailedTurn.progress) says so in the note; on the
2544
- # error (kept_steps) too unless it failed in a way that rolls back
2545
- # (FailedTurn.keeps_work?: a context overflow). The UIs keep a marked
2546
- # turn (TurnFlow#prompt_turn_failed), and roll back the others, with a
2547
- # note of their own; -p --non-interactive keeps what this keeps.
2548
- def failed_messages(turn, error)
2549
- kept = loop_conversation(error) || turn.messages
2550
- return nil unless kept
2551
-
2552
- steps = LLM::FailedTurn.progress(turn.session.messages, kept)
2553
- error.kept_steps = steps if steps && error.is_a?(LLM::FailedTurn) && LLM::FailedTurn.keeps_work?(error)
2554
- summary = error.respond_to?(:summary) ? error.summary : error.message
2555
- TurnNote.replace_trailing(kept, TurnNote.failed(summary, continued: turn.continue, steps: steps))
2556
- end
2444
+ def failed_messages(turn, error) = @turn_endings.failed_messages(turn, error)
2557
2445
  private :failed_messages
2558
-
2559
- # Called after #failed_messages, which marks the error's kept steps.
2560
- def failed_event(error, seconds)
2561
- failed = { type: :turn_failed, error_class: error.class.name, message: error.message,
2562
- duration_ms: (seconds * 1000).round }
2563
- # Its work stays: the UIs say so, as after a cancel.
2564
- steps = LLM::FailedTurn.kept_steps(error)
2565
- failed[:kept_steps] = steps if steps
2566
- # A provider error says what kind it is, for one line per kind in the UIs.
2567
- if error.is_a?(LLM::ProviderError)
2568
- failed.merge!(error_kind: error.kind, retryable: error.retryable?, host: error.host, summary: failure_summary(error))
2569
- end
2570
- failed
2571
- end
2446
+ def failed_event(error, seconds) = @turn_endings.failed_event(error, seconds)
2572
2447
  private :failed_event
2573
2448
 
2574
2449
  # However the turn ended (#run_turn's ensure): the turn flag off, the
@@ -2626,23 +2501,10 @@ module Samagotchi
2626
2501
  raise LLM::VisionUnsupported.new("#{host}: #{vision.refusal_reason}", host: host)
2627
2502
  end
2628
2503
 
2629
- # JSON-safe digest of a finished turn for renderers (in-process or over the
2630
- # 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).
2631
2505
  # @param result [LLM::ModelResult]
2632
2506
  # @return [Hash]
2633
- def turn_summary(result)
2634
- summary = {
2635
- output: result.output.to_s,
2636
- exhausted: result.exhausted?,
2637
- resumable: result.resumable?,
2638
- pending_tool_calls: result.pending_tool_calls?,
2639
- tool_activity: Array(result.tool_activity).map(&:dup),
2640
- context_status: result.context_status&.dup
2641
- }
2642
- # No answer: every UI shows its notice (TurnNote.empty_answer_line).
2643
- summary[:empty_answer] = { retries: result.empty_retries.to_i, refusal: result.refusal }.compact if result.empty_answer?
2644
- summary
2645
- end
2507
+ def turn_summary(result) = @turn_endings.turn_summary(result)
2646
2508
 
2647
2509
  # ── Session messages API ───────────────────────────────────────────────
2648
2510
  #
@@ -2893,7 +2755,7 @@ module Samagotchi
2893
2755
  model: model, source: source)
2894
2756
  },
2895
2757
  model_name: -> { effective_model_ref },
2896
- model_key: -> { @model_key },
2758
+ model_key: -> { @model_binding.model_key },
2897
2759
  state_dir: -> { session_state_dir },
2898
2760
  scratch: -> { @scratch },
2899
2761
  setup: -> { SessionSetup.of(@session) },
@@ -3010,253 +2872,47 @@ module Samagotchi
3010
2872
 
3011
2873
  # ── Event helpers ──────────────────────────────────────────────────────────
3012
2874
 
3013
- # Always return a handler so raw kernel loop events reach the persistent
3014
- # SessionObserver (and thus the analytics collector) even when there is no
3015
- # turn-scoped +on_event+ sink (e.g. the -p/--resume paths and
3016
- # SessionManager background workers). emit_event tolerates a nil on_event by
3017
- # only notifying the observer.
3018
- # Wrap a raw kernel stream event and fan it out to the turn sink + observers.
3019
- #
3020
- # Each :generation_chunk comes split already, by the loop that streamed it:
3021
- # besides the raw `content` (left untouched — the TUI thinking spinner,
3022
- # analytics, and Bridge replay rely on it) it carries
3023
- # * :text — visible prose (thinking AND tool_call blocks removed)
3024
- # * :thinking — thinking-only content
3025
- # KernelLoop splits a native stream with a ThoughtStreamSplitter per
3026
- # generation (every profile, Gemma's thought channel included); the chat
3027
- # loop gets reasoning apart from the answer. The web routes on those
3028
- # fields (chunk_router.js).
3029
- #
3030
- # With a :generation_progress hook registered, a Hooks::StreamWatch gets
3031
- # each chunk's thinking and text after the UIs had it.
3032
- #
3033
- # +model_ref+: the turn's model (#generation_model_ref), added to each
3034
- # :generation_started as `model_ref:` (both loops send through here).
3035
- def build_stream_event_handler(on_event, cancel_controller: nil, model_ref: nil)
3036
- watch = stream_watch(cancel_controller)
3037
- proc do |event|
3038
- progress = SteerCut.lanes(event) if event[:type] == :generation_chunk
3039
- @steer_cut.observe(event, progress)
3040
- case event[:type]
3041
- when :generation_started
3042
- event = event.merge(model_ref: model_ref) if model_ref
3043
- @thinking_notices.generation_started
3044
- watch&.started(event[:iteration])
3045
- when :generation_retrying
3046
- # A dropped stream's step asked again: it streams from the start.
3047
- if event[:restarted]
3048
- @thinking_notices.generation_started
3049
- watch&.started(event[:iteration], restarted: true)
3050
- end
3051
- when :generation_chunk then @thinking_notices.note_thought(progress[:thinking])
3052
- end
3053
- # The chat loop asked again without the thinking fields or the
3054
- # reasoning sent back: a notice, not an event of its own.
3055
- next @thinking_notices.thinking_refused(event) if event[:type] == :thinking_refused
3056
- next @thinking_notices.reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
3057
- next @thinking_notices.sampling_refused(event) if event[:type] == :sampling_refused
3058
-
3059
- event = with_generation_report(event) if event[:type] == :generation_completed
3060
- emit_event(on_event, event)
3061
- # What this request sends: both loops start a generation after
3062
- # their apply_llm_context!.
3063
- emit_working_set(on_event, @session, live_conversation) if event[:type] == :generation_started
3064
- watch.feed(**progress) if watch && progress
3065
- @thinking_notices.generation_completed(event) if event[:type] == :generation_completed
3066
- end
3067
- end
3068
-
3069
- # The working set (WorkingSet) as the model has it, as a
3070
- # :working_set_updated event ({items:, at:}, adds: while the user's
3071
- # adds are pending): at each request (the
3072
- # stream handler's :generation_started, from the running turn's
3073
- # conversation) and at turn end (#end_turn, from the session's kept
3074
- # messages). Only when it differs from the last one emitted for this
3075
- # session, and never while it is empty — except one empty event,
3076
- # remembered, when the last one for this session had items (a
3077
- # rollback removed the tool calls), so a tool-free turn emits none
3078
- # and the next empty request sends nothing more. The web overlays it
3079
- # on the rows' set; other UIs ignore it.
3080
- #
3081
- # Between turns (#announce_working_set: no +on_event+) it is
3082
- # announced, under the next turn's strategy.
3083
- def emit_working_set(on_event, session, conversation)
3084
- return unless session && conversation
3085
-
3086
- snapshot = WorkingSet.build(conversation, cwd: session.working_directory, strategy: working_set_strategy,
3087
- pins: session.working_set_pins)
3088
-
3089
- shown = snapshot.to_h.except(:at)
3090
- last = @working_set_emitted
3091
- if snapshot.empty?
3092
- return unless last && last[0] == session.id && last[1] != { items: [] }
3093
- elsif last == [session.id, shown]
3094
- return
3095
- end
3096
-
3097
- @working_set_emitted = [session.id, shown]
3098
- event = { type: :working_set_updated, **shown, at: snapshot.at }
3099
- on_event ? emit_event(on_event, event) : announce_or_hold(event)
3100
- rescue StandardError => e
3101
- Log.warn(:turn, "working_set_failed", error: e.class.name, msg: e.message.to_s[0, 200])
3102
- 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(...)
3103
2879
  private :emit_working_set
3104
-
3105
- # The strategy the set is built under: the running turn's
3106
- # (TurnSettings#llm_context: what its requests send); between turns
3107
- # the next turn's (as #warm_up_next_turn: the turn's settings keep the
3108
- # last turn's, stale after a /llm-context or /model change), the last
3109
- # turn's when it can't be resolved.
3110
- def working_set_llm_context
3111
- turn = @kernel.respond_to?(:turn_settings) ? @kernel.turn_settings&.llm_context : nil
3112
- return turn if turn_running?
3113
-
3114
- @model_settings.turn_llm_context || turn
3115
- end
2880
+ def working_set_llm_context = @event_fanout.working_set_llm_context
3116
2881
  private :working_set_llm_context
3117
-
3118
- # A :generation_completed with the generation's decode speed (`speed:`
3119
- # {decode_tps:, source:}, nil without one), the session's running
3120
- # token totals (`tokens:`, the snapshot's block) and its per-model rows
3121
- # (`model_segments:`, the snapshot's) and whether its served
3122
- # model is an expected one (`served_expected_by:`, its host): the web's info bar
3123
- # reads them from the stream. SessionMetrics closes the generation here,
3124
- # so the event it gets next finds it closed.
3125
- def with_generation_report(event)
3126
- report = @metrics.finish_generation
3127
- event.merge(speed: report.speed&.to_h, tokens: report.tokens, model_segments: report.model_segments,
3128
- served_expected_by: served_expected_by(event[:served_model]))
3129
- rescue StandardError => e
3130
- Log.warn(:turn, "generation_report_failed", error: e.class.name, msg: e.message.to_s[0, 200])
3131
- event
3132
- end
2882
+ def with_generation_report(event) = @event_fanout.with_generation_report(event)
3133
2883
  private :with_generation_report
3134
-
3135
- # The turn's StreamWatch, or nil when no hook listens.
3136
- def stream_watch(cancel_controller)
3137
- return nil unless cancel_controller && @hooks.any?(Hooks::StreamWatch::EVENT)
3138
-
3139
- Hooks::StreamWatch.new(hooks: @hooks, cancel_controller: cancel_controller)
3140
- end
2884
+ def stream_watch(cancel_controller) = @event_fanout.stream_watch(cancel_controller)
3141
2885
  private :stream_watch
3142
-
3143
- def emit_event(on_event, event)
3144
- # Capture used memories synchronously in the turn thread.
3145
- read_names = begin
3146
- @used_memories.capture(event, muted: @muted_memory_names)
3147
- rescue StandardError => e
3148
- Log.warn(:memory, "used_memories_capture_failed", error: e.class.name, msg: e.message.to_s[0, 200])
3149
- nil
3150
- end
3151
- # Turn-scoped sink: receives the event hash as the stream handler
3152
- # passed it (no event_seq; a :generation_completed has its report). Sink errors are isolated and never break the
3153
- # kernel loop (same as KernelLoop's own handling).
3154
- if on_event
3155
- begin
3156
- on_event.call(event)
3157
- rescue StandardError => e
3158
- # Sink errors must not break the kernel loop (same as KernelLoop's own handling)
3159
- Log.warn(:turn, "sink_failed", event_type: event[:type].to_s, error: e.class.name, msg: e.message.to_s[0, 200])
3160
- end
3161
- end
3162
-
3163
- # Persistent subscribers: receive a copy with a locally-monotonic
3164
- # `event_seq`, fan out with per-subscriber error isolation.
3165
- @session_observer.notify(event)
3166
- # Every memory read, after its tool_call_started: the whole list for
3167
- # the UIs' memory line, and the names this call read (read_names).
3168
- return unless read_names
3169
-
3170
- emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names, read_names: read_names })
3171
- end
2886
+ def emit_event(on_event, event) = @event_fanout.emit_event(on_event, event)
3172
2887
 
3173
2888
  # The effective model's window_tokens and price, read per turn
3174
2889
  # (SessionModelSettings).
3175
2890
  def turn_window_setting = @model_settings.turn_window_setting
3176
2891
  def turn_price = @model_settings.turn_price
3177
2892
 
3178
- # See #served_model. A report for another name (before a /model switch)
3179
- # doesn't count. Without a model (EffectiveModel), no probe. A remote
3180
- # chat host has no /props: nil until a turn. The name asked is never
3181
- # resolved (HostRegistry#bare_name), so it holds on a broken resolve.
3182
- def served_model_for(snapshot, model: nil)
3183
- asked = bare_model_name(model ? model.name : effective_model_name)
3184
- return [snapshot[:served_model], asked] if snapshot[:served_model] && snapshot[:served_model_for] == asked
3185
- return [nil, nil] unless model
3186
-
3187
- model.served_from_props(asked)
3188
- # Only bare_model_name and the snapshot lookup are left to fail here:
3189
- # the probe's failure is EffectiveModel#served_from_props's.
3190
- rescue StandardError
3191
- [nil, nil]
3192
- end
3193
-
3194
- # The next turn's kit prompt text for /stats: its kit and its estimated
3195
- # tokens (chars / 4), nil when its toolkit has none.
3196
- def kit_prompt_stats
3197
- resolved = toolkit_explained.resolved
3198
- return nil unless resolved.prompt
3199
-
3200
- { kit: resolved.name, tokens: (resolved.prompt.length / TokenUsage::CHARS_PER_TOKEN).ceil }
3201
- 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
3202
2897
 
3203
2898
  # The window as +model+'s loop would see it (EffectiveModel#context_window).
3204
2899
  def current_context_window(model) = model.context_window
3205
2900
 
3206
- # +settings+' chips (SessionSetting), the model's from +model+ (nil
3207
- # when it doesn't resolve): /stats, the session state, a :command_ran.
3208
- def setting_chips(settings, model)
3209
- settings.to_h { |setting| [setting.key, setting.model? ? setting.chip_for(model, @session) : toolkit_summary] }
3210
- end
2901
+ # +settings+' chips (EngineSnapshots#setting_chips).
2902
+ def setting_chips(settings, model) = @engine_snapshots.setting_chips(settings, model)
3211
2903
 
3212
2904
  # The effective model now, resolved on each call, never kept
3213
2905
  # (SessionModelSettings#effective_model). Raises as HostRegistry#resolve does.
3214
2906
  # @return [EffectiveModel]
3215
- def effective_model(choice = @model_choice) = @model_settings.effective_model(choice)
2907
+ def effective_model(choice = @model_binding.choice) = @model_settings.effective_model(choice)
3216
2908
 
3217
2909
  # ── Prompt profile ─────────────────────────────────────────────────────────
3218
2910
 
3219
- # +choice+'s profile (ModelProfile.resolve; may probe /props), or the
3220
- # one given to .new.
3221
- def resolve_profile(choice)
3222
- given = @given_profile
3223
- return ModelProfile::Resolution.new(profile: given, source: :given, detail: nil, retry: false) if given
3224
-
3225
- EffectiveModel.of(@host_registry, choice).profile_resolution
3226
- end
3227
-
3228
- # Keeps +resolution+ as +choice+'s and applies it, unless the model
3229
- # switched while it was resolved (a /stats on a Bridge thread probing
3230
- # as a /model ran): then it is only returned, and the new model's is
3231
- # resolved on its first need.
3232
- # @return [ModelProfile::Resolution]
3233
- def store_profile(choice, resolution)
3234
- @profile_mutex.synchronize do
3235
- return resolution unless @model_choice.equal?(choice)
3236
-
3237
- apply_profile(resolution)
3238
- @profile_resolution = [choice, resolution]
3239
- end
3240
- resolution
3241
- end
3242
-
3243
- # Everything that holds a profile follows the resolution: the kernel's
3244
- # prompt format and parser, and the system prompts built for the old one.
3245
- def apply_profile(resolution)
3246
- kept = @profile_resolution&.last
3247
- @prompt_builder&.reset! if kept && kept.profile.name != resolution.profile.name
3248
- @kernel.use_profile!(resolution)
3249
- resolution
3250
- end
3251
-
3252
- # Before a turn: resolve now if nothing has yet, or again if the last
3253
- # server probe failed (unreachable, or 503 while loading a model). A
3254
- # profile that changes here drops the cached system prompts.
3255
- def refresh_profile!
3256
- choice, kept = @profile_resolution
3257
- return profile_resolution unless choice.equal?(@model_choice) && kept.retry?
3258
-
3259
- store_profile(choice, resolve_profile(choice))
3260
- 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!
3261
2917
  end
3262
2918
  end