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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -1
- data/docs/architecture.md +8 -5
- data/docs/cli.md +3 -0
- data/docs/configuration.md +43 -0
- data/docs/internals/engine.md +39 -10
- data/docs/internals/participants.md +1 -1
- data/docs/internals/remote-sessions.md +3 -3
- data/docs/internals/web-push.md +200 -0
- data/docs/internals/working-set.md +7 -6
- data/docs/sessions.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/engine.rb +146 -490
- data/lib/samagotchi/engine_snapshots.rb +223 -0
- data/lib/samagotchi/event_fanout.rb +236 -0
- data/lib/samagotchi/model_binding.rb +138 -0
- data/lib/samagotchi/send_command/wait_target.rb +10 -3
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +12 -3
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +7 -3
- data/lib/samagotchi/session_manager.rb +5 -0
- data/lib/samagotchi/turn_endings.rb +137 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +27 -1377
- data/lib/samagotchi/web/grants.rb +3 -12
- data/lib/samagotchi/web/message_display.rb +274 -0
- data/lib/samagotchi/web/private_files.rb +31 -0
- data/lib/samagotchi/web/public/app.js +30 -6
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
- data/lib/samagotchi/web/public/index.html +4 -0
- data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
- data/lib/samagotchi/web/public/notifications.js +137 -3
- data/lib/samagotchi/web/public/notify.js +1 -1
- data/lib/samagotchi/web/public/push.js +95 -0
- data/lib/samagotchi/web/public/stage_view.js +8 -0
- data/lib/samagotchi/web/public/sw.js +101 -0
- data/lib/samagotchi/web/public/turn_events.js +22 -0
- data/lib/samagotchi/web/push/attention.rb +105 -0
- data/lib/samagotchi/web/push/client.rb +103 -0
- data/lib/samagotchi/web/push/crypto.rb +126 -0
- data/lib/samagotchi/web/push/notifier.rb +230 -0
- data/lib/samagotchi/web/push/store.rb +285 -0
- data/lib/samagotchi/web/push.rb +73 -0
- data/lib/samagotchi/web/push_routes.rb +99 -0
- data/lib/samagotchi/web/server.rb +26 -2
- data/lib/samagotchi/web/session_control.rb +441 -0
- data/lib/samagotchi/web/session_create.rb +384 -0
- data/lib/samagotchi/web/session_show.rb +411 -0
- data/lib/samagotchi/worker.rb +3 -2
- 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
|
-
|
|
26
|
-
|
|
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],
|
|
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
|
data/lib/samagotchi/session.rb
CHANGED
|
@@ -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).
|