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
@@ -0,0 +1,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session_setting"
4
+ require_relative "token_usage"
5
+
6
+ module Samagotchi
7
+ # The read model an Engine gives its UIs: #session_state_snapshot (cheap,
8
+ # never probes and never raises: the Bridge's /state and reset frames call
9
+ # it under the event lock) and #stats_snapshot (/stats, which may make one
10
+ # short /props GET), with their helpers.
11
+ #
12
+ # What it needs from the Engine comes through lookups, read at call time;
13
+ # the Engine forwards each method here. It takes no lock of its own: under
14
+ # the Bridge's event lock a snapshot takes the question desk's monitor and
15
+ # the metrics mutex, as before.
16
+ class EngineSnapshots
17
+ # State lookups, each [#call] → the Engine's object:
18
+ # @param session, metrics, observer, question_desk, used_memories, muted,
19
+ # prompt_builder, participants, participants_watch, recap,
20
+ # last_context_status, model_settings
21
+ # Engine calls (the Engine keeps these; specs stub some on it):
22
+ # @param effective_model, effective_model_name, effective_model_ref,
23
+ # bare_model_name (ref), served_model (probe:), prompt_notes, preloaded,
24
+ # saved_context_status (context, **opts), context_window (model),
25
+ # profile_resolution, toolkit_summary, toolkit_explained
26
+ def initialize(session:, metrics:, observer:, question_desk:, used_memories:, muted:, prompt_builder:,
27
+ participants:, participants_watch:, recap:, last_context_status:, model_settings:,
28
+ effective_model:, effective_model_name:, effective_model_ref:, bare_model_name:, served_model:,
29
+ prompt_notes:, preloaded:, saved_context_status:, context_window:, profile_resolution:,
30
+ toolkit_summary:, toolkit_explained:)
31
+ @session = session
32
+ @metrics = metrics
33
+ @observer = observer
34
+ @question_desk = question_desk
35
+ @used_memories = used_memories
36
+ @muted = muted
37
+ @prompt_builder = prompt_builder
38
+ @participants = participants
39
+ @participants_watch = participants_watch
40
+ @recap = recap
41
+ @last_context_status = last_context_status
42
+ @model_settings = model_settings
43
+ @effective_model = effective_model
44
+ @effective_model_name = effective_model_name
45
+ @effective_model_ref = effective_model_ref
46
+ @bare_model_name = bare_model_name
47
+ @served_model = served_model
48
+ @prompt_notes = prompt_notes
49
+ @preloaded = preloaded
50
+ @saved_context_status = saved_context_status
51
+ @context_window = context_window
52
+ @profile_resolution = profile_resolution
53
+ @toolkit_summary = toolkit_summary
54
+ @toolkit_explained = toolkit_explained
55
+ end
56
+ # rubocop:enable Metrics/ParameterLists
57
+
58
+ # The metrics for /stats. Before the first generation reports them, the
59
+ # context window, the served model and (on a native host) the prompt
60
+ # profile come from the effective model's server, so this may make one
61
+ # short /props GET; the cheap #session_state_snapshot never does.
62
+ # price_host: the effective model's host, whose hosts.<name>.models
63
+ # prices the cost estimate names. kit_prompt: the next turn's kit
64
+ # prompt text ({kit:, tokens:}), only when its toolkit has one.
65
+ # settings: the session's own settings ({"recap.enabled" => false}),
66
+ # only when it has some.
67
+ # @return [Hash] @metrics.snapshot, filled in
68
+ def stats_snapshot
69
+ snapshot = metrics.snapshot
70
+ # One resolve for the whole snapshot (its readers' rescues stay theirs);
71
+ # a model that doesn't resolve raises here, as it always has.
72
+ model = effective_model
73
+ served, served_for = served_model_for(snapshot, model: model)
74
+ snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
75
+ served_expected_by: model.served_expected_by(served),
76
+ **setting_chips(SessionSetting::STATS, model),
77
+ prompt_notes: prompt_notes.map(&:to_h), prompt_files: prompt_builder.prompt_files,
78
+ price_host: model.host_name)
79
+ kit_prompt = kit_prompt_stats
80
+ snapshot = snapshot.merge(kit_prompt: kit_prompt) if kit_prompt
81
+ # Each participant's spend under their name (participants.json's).
82
+ if (spend = snapshot[:spend_by_author])
83
+ named = spend.to_h { |author, row| [author, row.merge(name: participants.name_for(author, session: session))] }
84
+ snapshot = snapshot.merge(spend_by_author: named)
85
+ end
86
+ # The session's own settings (chi --set), as --set names them.
87
+ settings = session&.settings&.flat || {}
88
+ snapshot = snapshot.merge(settings: settings) unless settings.empty?
89
+ unless snapshot.dig(:context, :window_tokens)
90
+ window = current_context_window(model)
91
+ if window
92
+ snapshot = snapshot.merge(context: snapshot[:context].merge(window_tokens: window.tokens,
93
+ window_source: window.source.to_s))
94
+ end
95
+ end
96
+ # The chat loop uses no prompt profile: drop one a native turn reported.
97
+ return snapshot.except(:profile, :profile_source) if model.chat?
98
+
99
+ unless snapshot[:profile]
100
+ resolution = profile_resolution
101
+ snapshot = snapshot.merge(profile: resolution.profile.name, profile_source: resolution.label)
102
+ end
103
+ snapshot
104
+ end
105
+
106
+ # Read-only snapshot of the engine's view of the current session plus the
107
+ # live event sequence. Cheap primitive used by the bridge's reconnect-too-
108
+ # old reset marker and the GET /session/:id/state read surface. Orthogonal
109
+ # to the transport — safe to call before the first turn (nil session).
110
+ #
111
+ # @return [Hash] with keys:
112
+ # :status [String, nil] current session status
113
+ # :message_count [Integer] number of messages in the session
114
+ # :last_prompt [String, nil] the last user prompt (empty string if none)
115
+ # :event_seq [Integer] @session_observer.event_count
116
+ # :metrics [Hash] @metrics.snapshot (per-session analytics)
117
+ # :pending_question [Hash, nil] the pending question (PendingQuestion#to_h)
118
+ # :used_memory_names [Array<String>] deduped memory names active this session
119
+ # :prompt_notes [Array<Hash>] the model notes the prompt carried
120
+ # ({name:, scope:, chars:, digest:}; #prompt_notes)
121
+ # :parent_id [String, nil] the session that delegated this one
122
+ # :model_name [String] the model turns run on now (after /model)
123
+ # :served_model, :served_model_for [String, nil] what a generation of
124
+ # that model reported serving, and the name asked (#served_model
125
+ # without the probe)
126
+ # :served_expected_by [String, nil] the host whose models: entry names
127
+ # that served model under served: (#served_expected_by)
128
+ # :recap_enabled [Boolean] whether an idle recap is configured, with
129
+ # :recap_min_user_turns and :recap_inactivity_seconds (nil when not)
130
+ # :participants [Array<Hash>] who is in the review now ({id:, name:,
131
+ # kind:}; ParticipantsWatch#active), [] for nobody
132
+ def session_state_snapshot
133
+ served_pair = served_model(probe: false)
134
+ metrics = self.metrics.snapshot
135
+ # One resolve for the model's fields; nil when it doesn't resolve (the
136
+ # Bridge's /state and reset frames rely on this never raising): those
137
+ # fields are nil then, as their own rescues always gave.
138
+ model = model_settings.model_setting(nil) { |resolved| resolved }
139
+ {
140
+ status: session&.status,
141
+ message_count: (session&.messages || []).size,
142
+ last_prompt: session&.last_prompt,
143
+ event_seq: observer&.event_count,
144
+ metrics: metrics,
145
+ pending_question: question_desk.pending&.to_h,
146
+ used_memory_names: used_memories.names,
147
+ preloaded_memory_names: preloaded_memory_names,
148
+ muted_memory_names: muted.dup,
149
+ prompt_notes: prompt_notes.map(&:to_h),
150
+ parent_id: session&.parent_id,
151
+ title: session&.title,
152
+ model_name: effective_model_ref,
153
+ served_model: served_pair[0],
154
+ served_model_for: served_pair[1],
155
+ served_expected_by: model&.served_expected_by(served_pair[0]),
156
+ context_status: last_context_status&.dup || saved_context_status(metrics[:context], model: model),
157
+ **setting_chips(SessionSetting::ALL, model),
158
+ recap_enabled: !recap.nil?,
159
+ recap_min_user_turns: recap&.min_user_turns,
160
+ recap_inactivity_seconds: recap&.inactivity&.to_i,
161
+ participants: participants_watch.active
162
+ }
163
+ end
164
+
165
+ # See Engine#served_model. A report for another name (before a /model switch)
166
+ # doesn't count. Without a model (EffectiveModel), no probe. A remote
167
+ # chat host has no /props: nil until a turn. The name asked is never
168
+ # resolved (HostRegistry#bare_name), so it holds on a broken resolve.
169
+ def served_model_for(snapshot, model: nil)
170
+ asked = bare_model_name(model ? model.name : effective_model_name)
171
+ return [snapshot[:served_model], asked] if snapshot[:served_model] && snapshot[:served_model_for] == asked
172
+ return [nil, nil] unless model
173
+
174
+ model.served_from_props(asked)
175
+ # Only bare_model_name and the snapshot lookup are left to fail here:
176
+ # the probe's failure is EffectiveModel#served_from_props's.
177
+ rescue StandardError
178
+ [nil, nil]
179
+ end
180
+
181
+ # The next turn's kit prompt text for /stats: its kit and its estimated
182
+ # tokens (chars / 4), nil when its toolkit has none.
183
+ def kit_prompt_stats
184
+ resolved = toolkit_explained.resolved
185
+ return nil unless resolved.prompt
186
+
187
+ { kit: resolved.name, tokens: (resolved.prompt.length / TokenUsage::CHARS_PER_TOKEN).ceil }
188
+ end
189
+
190
+ # +settings+' chips (SessionSetting), the model's from +model+ (nil
191
+ # when it doesn't resolve): /stats, the session state, a :command_ran.
192
+ def setting_chips(settings, model)
193
+ settings.to_h { |setting| [setting.key, setting.model? ? setting.chip_for(model, session) : toolkit_summary] }
194
+ end
195
+
196
+ private
197
+
198
+ def session = @session.call
199
+ def metrics = @metrics.call
200
+ def observer = @observer.call
201
+ def question_desk = @question_desk.call
202
+ def used_memories = @used_memories.call
203
+ def muted = @muted.call
204
+ def prompt_builder = @prompt_builder.call
205
+ def participants = @participants.call
206
+ def participants_watch = @participants_watch.call
207
+ def recap = @recap.call
208
+ def last_context_status = @last_context_status.call
209
+ def model_settings = @model_settings.call
210
+ def effective_model = @effective_model.call
211
+ def effective_model_name = @effective_model_name.call
212
+ def effective_model_ref = @effective_model_ref.call
213
+ def bare_model_name(ref) = @bare_model_name.call(ref)
214
+ def served_model(probe:) = @served_model.call(probe: probe)
215
+ def prompt_notes = @prompt_notes.call
216
+ def preloaded_memory_names = @preloaded.call
217
+ def saved_context_status(context, **) = @saved_context_status.call(context, **)
218
+ def current_context_window(model) = @context_window.call(model)
219
+ def profile_resolution = @profile_resolution.call
220
+ def toolkit_summary = @toolkit_summary.call
221
+ def toolkit_explained = @toolkit_explained.call
222
+ end
223
+ end
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "hooks"
4
+ require_relative "log"
5
+ require_relative "steer_cut"
6
+ require_relative "working_set"
7
+
8
+ module Samagotchi
9
+ # How one of an Engine's events reaches the turn's sink and the persistent
10
+ # observers (#emit_event: the used-memories capture, the isolated sink
11
+ # call, the observer notify, the follow-up :used_memories_updated), the
12
+ # stream handler that wraps each raw loop event (#build_stream_event_handler:
13
+ # SteerCut, ThinkingNotices, the StreamWatch, the generation report, the
14
+ # working set at each request), and the working set's dedupe
15
+ # (#emit_working_set).
16
+ #
17
+ # What it needs from the Engine comes through lookups, read at call time;
18
+ # the Engine forwards each method here. It takes no lock: the observers'
19
+ # notify takes the event lock, as before. #announce_or_hold stays the
20
+ # Engine's: its thread-local is keyed on the Engine.
21
+ class EventFanout
22
+ # @param observer [#call] → SessionObserver
23
+ # @param used_memories [#call] → UsedMemories
24
+ # @param muted [#call] → Array<String>, the muted memory names
25
+ # @param steer_cut [#call] → SteerCut
26
+ # @param thinking_notices [#call] → ThinkingNotices
27
+ # @param metrics [#call] → SessionMetrics
28
+ # @param hooks [#call] → Hooks::Registry
29
+ # @param kernel [#call] → KernelLoop
30
+ # @param model_settings [#call] → SessionModelSettings
31
+ # @param session [#call] → Session, nil
32
+ # @param live_conversation [#call] → Array<Hash>, the running turn's conversation
33
+ # @param used_memory_names [#call] → Array<String>
34
+ # @param served_expected_by [#call] (served) → the host expecting the served model
35
+ # @param working_set_strategy [#call] → the strategy the working set is built under
36
+ # @param turn_running [#call] → Boolean
37
+ # @param announce_or_hold [#call] (event) → announces the event, or holds it
38
+ def initialize(observer:, used_memories:, muted:, steer_cut:, thinking_notices:, metrics:, hooks:, kernel:,
39
+ model_settings:, session:, live_conversation:, used_memory_names:, served_expected_by:,
40
+ working_set_strategy:, turn_running:, announce_or_hold:)
41
+ @observer = observer
42
+ @used_memories = used_memories
43
+ @muted = muted
44
+ @steer_cut = steer_cut
45
+ @thinking_notices = thinking_notices
46
+ @metrics = metrics
47
+ @hooks = hooks
48
+ @kernel = kernel
49
+ @model_settings = model_settings
50
+ @session = session
51
+ @live_conversation = live_conversation
52
+ @used_memory_names = used_memory_names
53
+ @served_expected_by = served_expected_by
54
+ @working_set_strategy = working_set_strategy
55
+ @turn_running = turn_running
56
+ @announce_or_hold = announce_or_hold
57
+ @working_set_emitted = nil
58
+ end
59
+ # rubocop:enable Metrics/ParameterLists
60
+
61
+ # Always return a handler so raw kernel loop events reach the persistent
62
+ # SessionObserver (and thus the analytics collector) even when there is no
63
+ # turn-scoped +on_event+ sink (e.g. the -p/--resume paths and
64
+ # SessionManager background workers). emit_event tolerates a nil on_event by
65
+ # only notifying the observer.
66
+ # Wrap a raw kernel stream event and fan it out to the turn sink + observers.
67
+ #
68
+ # Each :generation_chunk comes split already, by the loop that streamed it:
69
+ # besides the raw `content` (left untouched — the TUI thinking spinner,
70
+ # analytics, and Bridge replay rely on it) it carries
71
+ # * :text — visible prose (thinking AND tool_call blocks removed)
72
+ # * :thinking — thinking-only content
73
+ # KernelLoop splits a native stream with a ThoughtStreamSplitter per
74
+ # generation (every profile, Gemma's thought channel included); the chat
75
+ # loop gets reasoning apart from the answer. The web routes on those
76
+ # fields (chunk_router.js).
77
+ #
78
+ # With a :generation_progress hook registered, a Hooks::StreamWatch gets
79
+ # each chunk's thinking and text after the UIs had it.
80
+ #
81
+ # +model_ref+: the turn's model (#generation_model_ref), added to each
82
+ # :generation_started as `model_ref:` (both loops send through here).
83
+ def build_stream_event_handler(on_event, cancel_controller: nil, model_ref: nil)
84
+ watch = stream_watch(cancel_controller)
85
+ proc do |event|
86
+ progress = SteerCut.lanes(event) if event[:type] == :generation_chunk
87
+ steer_cut.observe(event, progress)
88
+ case event[:type]
89
+ when :generation_started
90
+ event = event.merge(model_ref: model_ref) if model_ref
91
+ thinking_notices.generation_started
92
+ watch&.started(event[:iteration])
93
+ when :generation_retrying
94
+ # A dropped stream's step asked again: it streams from the start.
95
+ if event[:restarted]
96
+ thinking_notices.generation_started
97
+ watch&.started(event[:iteration], restarted: true)
98
+ end
99
+ when :generation_chunk then thinking_notices.note_thought(progress[:thinking])
100
+ end
101
+ # The chat loop asked again without the thinking fields or the
102
+ # reasoning sent back: a notice, not an event of its own.
103
+ next thinking_notices.thinking_refused(event) if event[:type] == :thinking_refused
104
+ next thinking_notices.reasoning_history_refused(event) if event[:type] == :reasoning_history_refused
105
+ next thinking_notices.sampling_refused(event) if event[:type] == :sampling_refused
106
+
107
+ event = with_generation_report(event) if event[:type] == :generation_completed
108
+ emit_event(on_event, event)
109
+ # What this request sends: both loops start a generation after
110
+ # their apply_llm_context!.
111
+ emit_working_set(on_event, session, live_conversation) if event[:type] == :generation_started
112
+ watch.feed(**progress) if watch && progress
113
+ thinking_notices.generation_completed(event) if event[:type] == :generation_completed
114
+ end
115
+ end
116
+
117
+ # The working set (WorkingSet) as the model has it, as a
118
+ # :working_set_updated event ({items:, at:}, adds: while the user's
119
+ # adds are pending): at each request (the
120
+ # stream handler's :generation_started, from the running turn's
121
+ # conversation) and at turn end (#end_turn, from the session's kept
122
+ # messages). Only when it differs from the last one emitted for this
123
+ # session, and never while it is empty — except one empty event,
124
+ # remembered, when the last one for this session had items (a
125
+ # rollback removed the tool calls), so a tool-free turn emits none
126
+ # and the next empty request sends nothing more. The web overlays it
127
+ # on the rows' set; other UIs ignore it.
128
+ #
129
+ # Between turns (#announce_working_set: no +on_event+) it is
130
+ # announced, under the next turn's strategy.
131
+ def emit_working_set(on_event, session, conversation)
132
+ return unless session && conversation
133
+
134
+ snapshot = WorkingSet.build(conversation, cwd: session.working_directory, strategy: working_set_strategy,
135
+ pins: session.working_set_pins)
136
+
137
+ shown = snapshot.to_h.except(:at)
138
+ last = @working_set_emitted
139
+ if snapshot.empty?
140
+ return unless last && last[0] == session.id && last[1] != { items: [] }
141
+ elsif last == [session.id, shown]
142
+ return
143
+ end
144
+
145
+ @working_set_emitted = [session.id, shown]
146
+ event = { type: :working_set_updated, **shown, at: snapshot.at }
147
+ on_event ? emit_event(on_event, event) : announce_or_hold(event)
148
+ rescue StandardError => e
149
+ Log.warn(:turn, "working_set_failed", error: e.class.name, msg: e.message.to_s[0, 200])
150
+ end
151
+
152
+ # The strategy the set is built under: the running turn's
153
+ # (TurnSettings#llm_context: what its requests send); between turns
154
+ # the next turn's (as #warm_up_next_turn: the turn's settings keep the
155
+ # last turn's, stale after a /llm-context or /model change), the last
156
+ # turn's when it can't be resolved.
157
+ def working_set_llm_context
158
+ turn = kernel.respond_to?(:turn_settings) ? kernel.turn_settings&.llm_context : nil
159
+ return turn if turn_running?
160
+
161
+ model_settings.turn_llm_context || turn
162
+ end
163
+
164
+ # A :generation_completed with the generation's decode speed (`speed:`
165
+ # {decode_tps:, source:}, nil without one), the session's running
166
+ # token totals (`tokens:`, the snapshot's block) and its per-model rows
167
+ # (`model_segments:`, the snapshot's) and whether its served
168
+ # model is an expected one (`served_expected_by:`, its host): the web's info bar
169
+ # reads them from the stream. SessionMetrics closes the generation here,
170
+ # so the event it gets next finds it closed.
171
+ def with_generation_report(event)
172
+ report = metrics.finish_generation
173
+ event.merge(speed: report.speed&.to_h, tokens: report.tokens, model_segments: report.model_segments,
174
+ served_expected_by: served_expected_by(event[:served_model]))
175
+ rescue StandardError => e
176
+ Log.warn(:turn, "generation_report_failed", error: e.class.name, msg: e.message.to_s[0, 200])
177
+ event
178
+ end
179
+
180
+ # The turn's StreamWatch, or nil when no hook listens.
181
+ def stream_watch(cancel_controller)
182
+ return nil unless cancel_controller && hooks.any?(Hooks::StreamWatch::EVENT)
183
+
184
+ Hooks::StreamWatch.new(hooks: hooks, cancel_controller: cancel_controller)
185
+ end
186
+
187
+ def emit_event(on_event, event)
188
+ # Capture used memories synchronously in the turn thread.
189
+ read_names = begin
190
+ used_memories.capture(event, muted: muted)
191
+ rescue StandardError => e
192
+ Log.warn(:memory, "used_memories_capture_failed", error: e.class.name, msg: e.message.to_s[0, 200])
193
+ nil
194
+ end
195
+ # Turn-scoped sink: receives the event hash as the stream handler
196
+ # passed it (no event_seq; a :generation_completed has its report). Sink errors are isolated and never break the
197
+ # kernel loop (same as KernelLoop's own handling).
198
+ if on_event
199
+ begin
200
+ on_event.call(event)
201
+ rescue StandardError => e
202
+ # Sink errors must not break the kernel loop (same as KernelLoop's own handling)
203
+ Log.warn(:turn, "sink_failed", event_type: event[:type].to_s, error: e.class.name, msg: e.message.to_s[0, 200])
204
+ end
205
+ end
206
+
207
+ # Persistent subscribers: receive a copy with a locally-monotonic
208
+ # `event_seq`, fan out with per-subscriber error isolation.
209
+ observer.notify(event)
210
+ # Every memory read, after its tool_call_started: the whole list for
211
+ # the UIs' memory line, and the names this call read (read_names).
212
+ return unless read_names
213
+
214
+ emit_event(on_event, { type: :used_memories_updated, used_memory_names: used_memory_names, read_names: read_names })
215
+ end
216
+
217
+ private
218
+
219
+ def observer = @observer.call
220
+ def used_memories = @used_memories.call
221
+ def muted = @muted.call
222
+ def steer_cut = @steer_cut.call
223
+ def thinking_notices = @thinking_notices.call
224
+ def metrics = @metrics.call
225
+ def hooks = @hooks.call
226
+ def kernel = @kernel.call
227
+ def model_settings = @model_settings.call
228
+ def session = @session.call
229
+ def live_conversation = @live_conversation.call
230
+ def used_memory_names = @used_memory_names.call
231
+ def served_expected_by(served) = @served_expected_by.call(served)
232
+ def working_set_strategy = @working_set_strategy.call
233
+ def turn_running? = @turn_running.call
234
+ def announce_or_hold(event) = @announce_or_hold.call(event)
235
+ end
236
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "effective_model"
4
+ require_relative "model_overlay"
5
+ require_relative "model_profile"
6
+
7
+ module Samagotchi
8
+ # An Engine's model choice (ModelChoice: the model as given and as typed)
9
+ # and the prompt profile resolved for it, under one mutex; the counterpart
10
+ # of ToolkitBinding. The memory overlay keys follow the choice
11
+ # (#sync_model_key!).
12
+ #
13
+ # The choice changes in one assignment under the mutex (#switch!), so a
14
+ # reader on another thread sees the old pair or the new one. A resolution
15
+ # is kept and applied only for the choice it was resolved for, by
16
+ # identity (#store_profile: a /stats on a Bridge thread probing while
17
+ # /model runs keeps nothing). Nothing else takes the mutex.
18
+ #
19
+ # What it needs from the Engine comes through lookups, called at use; the
20
+ # Engine forwards each method here.
21
+ class ModelBinding
22
+ # @param choice [ModelChoice] the starting model
23
+ # @param given_profile [ModelProfile, nil] a caller's profile (until a switch)
24
+ # @param registry [HostRegistry]
25
+ # @param kernel [#call] → KernelLoop
26
+ # @param prompt_builder [#call] → SystemPrompt, nil while the Engine is built
27
+ # @param bare_name [#call] (ref) → the model id sent for +ref+
28
+ def initialize(choice:, given_profile:, registry:, kernel:, prompt_builder:, bare_name:)
29
+ @choice = choice
30
+ @given_profile = given_profile
31
+ @registry = registry
32
+ @kernel = kernel
33
+ @prompt_builder = prompt_builder
34
+ @bare_name = bare_name
35
+ # Guards the model choice and the profile cached for it (#store_profile).
36
+ @mutex = Mutex.new
37
+ # [choice, resolution]: the resolution kept, with the choice it is for.
38
+ @resolution = nil
39
+ @model_key = nil
40
+ @fallback_model_key = nil
41
+ end
42
+
43
+ # @return [ModelChoice] the model turns run on now
44
+ attr_reader :choice
45
+
46
+ # @return [ModelProfile, nil] the profile given to the Engine, nil after a switch
47
+ attr_reader :given_profile
48
+
49
+ # The effective model's key (ModelOverlay.key_for its bare name), and
50
+ # the typed name's when that differs (#sync_model_key!).
51
+ # @return [String, nil]
52
+ attr_reader :model_key, :fallback_model_key
53
+
54
+ # Moves to +choice+ (Engine#switch_model!): the given profile and the
55
+ # kept resolution go with the old one.
56
+ def switch!(choice)
57
+ @mutex.synchronize do
58
+ # One assignment: a reader on another thread sees the old pair or the new one.
59
+ @choice = choice
60
+ # A profile given to .new was for the starting model.
61
+ @given_profile = nil
62
+ @resolution = nil
63
+ end
64
+ end
65
+
66
+ # Which profile the effective model gets and where that came from
67
+ # (ModelProfile.resolve: --profile/env, models:, hosts.<name>.profile,
68
+ # the server's chat template, the name, qwen36). Resolved on first need
69
+ # and kept, so the system prompt and the server's KV prefix stay stable;
70
+ # #switch! starts over, and a failed server probe is retried before
71
+ # the next turn (#refresh_profile!). The kernel follows each resolution.
72
+ # Kept with the model choice it was resolved for: one that finishes
73
+ # after a switch is neither kept nor applied (#store_profile).
74
+ # @return [ModelProfile::Resolution]
75
+ def profile_resolution
76
+ choice = @choice
77
+ cached = @resolution
78
+ return cached.last if cached&.first.equal?(choice)
79
+
80
+ store_profile(choice, resolve_profile(choice))
81
+ end
82
+
83
+ # +choice+'s profile (ModelProfile.resolve; may probe /props), or the
84
+ # one given to .new.
85
+ def resolve_profile(choice)
86
+ given = @given_profile
87
+ return ModelProfile::Resolution.new(profile: given, source: :given, detail: nil, retry: false) if given
88
+
89
+ EffectiveModel.of(@registry, choice).profile_resolution
90
+ end
91
+
92
+ # Keeps +resolution+ as +choice+'s and applies it, unless the model
93
+ # switched while it was resolved (a /stats on a Bridge thread probing
94
+ # as a /model ran): then it is only returned, and the new model's is
95
+ # resolved on its first need.
96
+ # @return [ModelProfile::Resolution]
97
+ def store_profile(choice, resolution)
98
+ @mutex.synchronize do
99
+ return resolution unless @choice.equal?(choice)
100
+
101
+ apply_profile(resolution)
102
+ @resolution = [choice, resolution]
103
+ end
104
+ resolution
105
+ end
106
+
107
+ # Everything that holds a profile follows the resolution: the kernel's
108
+ # prompt format and parser, and the system prompts built for the old one.
109
+ def apply_profile(resolution)
110
+ kept = @resolution&.last
111
+ @prompt_builder.call&.reset! if kept && kept.profile.name != resolution.profile.name
112
+ @kernel.call.use_profile!(resolution)
113
+ resolution
114
+ end
115
+
116
+ # Before a turn: resolve now if nothing has yet, or again if the last
117
+ # server probe failed (unreachable, or 503 while loading a model). A
118
+ # profile that changes here drops the cached system prompts.
119
+ def refresh_profile!
120
+ choice, kept = @resolution
121
+ return profile_resolution unless choice.equal?(@choice) && kept.retry?
122
+
123
+ store_profile(choice, resolve_profile(choice))
124
+ end
125
+
126
+ # The memory overlay key follows the model sent (ModelOverlay.key_for
127
+ # its id). A model typed as an alias used to key overlays by the alias
128
+ # (before aliases were applied there): that key is read when the
129
+ # target's overlay is missing (KernelLoop#sync_model_key!).
130
+ def sync_model_key!
131
+ choice = @choice
132
+ @model_key = ModelOverlay.key_for(@bare_name.call(choice.name))
133
+ typed_key = ModelOverlay.key_for(@registry.parse_qualified_model(choice.typed).last)
134
+ @fallback_model_key = typed_key == @model_key ? nil : typed_key
135
+ @kernel.call.sync_model_key!(@model_key, fallback: @fallback_model_key)
136
+ end
137
+ end
138
+ end
@@ -19,11 +19,18 @@ module Samagotchi
19
19
  # settle end the wait (TurnCheckpoint.settled_turn). A message merged
20
20
  # into a turn starting then falls back to that turn's reply
21
21
  # (ReplyWait's turn: without queued:); no id (an older worker), the
22
- # next reply.
22
+ # next reply. A prompt the session was created with has no delivery:
23
+ # its turn is the id the create gave it (+direct_turn+,
24
+ # Session#first_turn_id), and nothing can merge into it, so the wait
25
+ # is for that turn alone (ReplyWait's queued:).
23
26
  # @param delivered [SessionManager::DeliverResult, nil] nil when the
24
27
  # message started the session itself (no delivery)
25
- def self.first_message(delivered)
26
- return new unless delivered
28
+ # @param direct_turn [String, nil] that first turn's id, for a direct
29
+ # start
30
+ def self.first_message(delivered, direct_turn: nil)
31
+ return new unless delivered || !direct_turn.to_s.strip.empty?
32
+
33
+ return new(turn: direct_turn, report_turn_id: direct_turn, queued: true) if delivered.nil?
27
34
 
28
35
  new(turn: delivered.enqueued_id, report_turn_id: delivered.enqueued_id)
29
36
  end
@@ -521,7 +521,8 @@ module Samagotchi
521
521
 
522
522
  start = @access.prepare_wait(session.id, session: session, created: true)
523
523
  wait_for_reply(session.id, cursor: start.cursor, baseline: start.baseline,
524
- timeout: options[:timeout], **WaitTarget.first_message(@delivered).to_h)
524
+ timeout: options[:timeout],
525
+ **WaitTarget.first_message(@delivered, direct_turn: session.first_turn_id).to_h)
525
526
  end
526
527
 
527
528
  # What chi send --new asks to create: through the worker (images, a
@@ -54,6 +54,13 @@ module Samagotchi
54
54
  attr_accessor :id, :metadata_version, :mode, :model_name, :working_directory, :messages,
55
55
  :created_at, :updated_at, :status, :last_prompt, :first_preview, :test_run,
56
56
  :used_memory_names
57
+ # The id the prompt a session is created with gets (SessionManager
58
+ # .spawn_session), so its first turn is a turn with an id like one sent
59
+ # later: the worker's first turn takes it as its origin, its reply file
60
+ # and last_turn carry it, and a wait for it (`chi send --new --wait`,
61
+ # chi web's create) can name it (`--wait --turn T ID`). nil for a
62
+ # session created idle, and for one saved before the field existed.
63
+ attr_accessor :first_turn_id
57
64
  # Memories the session was started with (--memory) and memories hidden
58
65
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
59
66
  # Names as given; the engine normalizes them.
@@ -310,7 +317,7 @@ module Samagotchi
310
317
 
311
318
  def initialize(id:, mode:, model_name:, working_directory:, messages:, created_at:, updated_at:,
312
319
  metadata_version: METADATA_VERSION, status: STATUS_IDLE, last_prompt: "",
313
- first_preview: "", test_run: false, pending_question: nil,
320
+ first_preview: "", first_turn_id: nil, test_run: false, pending_question: nil,
314
321
  used_memory_names: [], project_root: nil,
315
322
  preloaded_memory_names: [], muted_memory_names: [], parent_id: nil, scratch: false,
316
323
  last_turn: nil, model_typed: nil, delegate: false, llm_context: nil, prompt_notes: [],
@@ -329,6 +336,7 @@ module Samagotchi
329
336
  @status = status
330
337
  @last_prompt = last_prompt
331
338
  @first_preview = first_preview
339
+ @first_turn_id = first_turn_id
332
340
  @title = title
333
341
  @test_run = !!test_run
334
342
  self.pending_question = pending_question
@@ -525,12 +533,13 @@ module Samagotchi
525
533
  "interrupted_turn" => nil,
526
534
  "parent_answers" => nil,
527
535
  "working_set_pins" => [],
528
- "review" => nil
536
+ "review" => nil,
537
+ "first_turn_id" => nil
529
538
  }.freeze
530
539
 
531
540
  # FIELDS a session file has only when set: a session without them saves
532
541
  # as it did before they existed.
533
- OPTIONAL_FIELDS = %w[review].freeze
542
+ OPTIONAL_FIELDS = %w[review first_turn_id].freeze
534
543
 
535
544
  # A session from a parsed session file. +messages+ false leaves the
536
545
  # conversation out (the lists' lightweight summaries).