samagotchi 0.109.0 → 0.111.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +9 -6
- data/docs/cli.md +43 -1
- data/docs/configuration.md +140 -3
- data/docs/internals/engine.md +58 -14
- data/docs/internals/participants.md +7 -4
- data/docs/internals/prompt-caching.md +5 -0
- 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/plugins.md +1 -1
- data/docs/sessions.md +4 -2
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/config.rb +4 -0
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/engine.rb +166 -499
- data/lib/samagotchi/engine_snapshots.rb +223 -0
- data/lib/samagotchi/event_fanout.rb +236 -0
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_reminders.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +12 -7
- data/lib/samagotchi/llm/chat_loop.rb +9 -2
- data/lib/samagotchi/message_time.rb +104 -0
- data/lib/samagotchi/model_binding.rb +138 -0
- data/lib/samagotchi/reminder_queue.rb +20 -3
- data/lib/samagotchi/reply_wait/poll.rb +39 -1
- data/lib/samagotchi/run_options.rb +4 -1
- 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/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +11 -5
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +7 -3
- data/lib/samagotchi/session_inbox.rb +7 -3
- data/lib/samagotchi/session_manager.rb +5 -0
- data/lib/samagotchi/steer.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
- data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
- data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
- data/lib/samagotchi/terminal_ui.rb +40 -5
- data/lib/samagotchi/tool_activity.rb +11 -0
- data/lib/samagotchi/turn_checkpoint.rb +6 -1
- data/lib/samagotchi/turn_endings.rb +137 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -1369
- data/lib/samagotchi/web/grants.rb +3 -12
- data/lib/samagotchi/web/message_display.rb +274 -0
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/private_files.rb +31 -0
- data/lib/samagotchi/web/public/app.js +41 -9
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/format.js +72 -6
- 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 +6 -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/participant.html +2 -0
- data/lib/samagotchi/web/public/participant.js +30 -7
- data/lib/samagotchi/web/public/participant_model.js +25 -2
- data/lib/samagotchi/web/public/push.js +95 -0
- data/lib/samagotchi/web/public/stage_view.js +8 -0
- data/lib/samagotchi/web/public/stream_handlers.js +32 -4
- data/lib/samagotchi/web/public/sw.js +101 -0
- data/lib/samagotchi/web/public/time_gaps.js +98 -0
- data/lib/samagotchi/web/public/tokens.css +2 -2
- data/lib/samagotchi/web/public/turn_events.js +25 -1
- data/lib/samagotchi/web/public/turn_html.js +15 -8
- data/lib/samagotchi/web/public/turn_view.js +8 -3
- 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/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +112 -32
- 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/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- data/lib/samagotchi/worker.rb +3 -2
- metadata +30 -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
|
|
@@ -504,7 +504,7 @@ module Samagotchi
|
|
|
504
504
|
model: served || job[:model], created_at: Time.now.utc.iso8601 }
|
|
505
505
|
@mutex.synchronize { @state = saved }
|
|
506
506
|
save(saved)
|
|
507
|
-
@engine.emit_recap(recap: text, generation: job[:generation], covered: job[:covered])
|
|
507
|
+
@engine.emit_recap(recap: text, generation: job[:generation], covered: job[:covered], created_at: saved[:created_at])
|
|
508
508
|
rescue StandardError
|
|
509
509
|
@in_flight = nil
|
|
510
510
|
end
|
|
@@ -8,7 +8,7 @@ module Samagotchi
|
|
|
8
8
|
#
|
|
9
9
|
# Two delivery paths cooperate:
|
|
10
10
|
# 1. Every turn injects what is due (ReminderQueue#inject!, from
|
|
11
|
-
# Engine#run_turn) as [SYSTEM: REMINDERS DUE] and marks it fired.
|
|
11
|
+
# Engine#run_turn) as [SYSTEM: REMINDERS DUE <local time>] and marks it fired.
|
|
12
12
|
# 2. Synthetic turns: when this job's tick finds due reminders while
|
|
13
13
|
# idle, it fires @auto_turn_callback, which queues their names
|
|
14
14
|
# (Engine#note_due_reminders) for the worker's or the REPL's
|
|
@@ -33,6 +33,7 @@ require_relative "llm_context_forget"
|
|
|
33
33
|
require_relative "working_set"
|
|
34
34
|
require_relative "llm_context_notice"
|
|
35
35
|
require_relative "answer_display"
|
|
36
|
+
require_relative "message_time"
|
|
36
37
|
require_relative "llm/model_result"
|
|
37
38
|
require_relative "llm/turn_settings"
|
|
38
39
|
require_relative "llm/stream_drops"
|
|
@@ -360,7 +361,8 @@ module Samagotchi
|
|
|
360
361
|
return after_malformed(turn, generation, reason)
|
|
361
362
|
end
|
|
362
363
|
|
|
363
|
-
turn.conversation << { role: "model", content: request.prefill + generation.response.to_s
|
|
364
|
+
turn.conversation << { role: "model", content: request.prefill + generation.response.to_s,
|
|
365
|
+
MessageTime::KEY => MessageTime.iso(MessageTime.now) }
|
|
364
366
|
# Profile-specific parse (incl. Qwen unterminated-block recovery); the
|
|
365
367
|
# returned fragment (non-nil only for Qwen) is fed back on the next
|
|
366
368
|
# iteration if the model opened a tool-call block it did not close.
|
|
@@ -867,7 +869,8 @@ module Samagotchi
|
|
|
867
869
|
# cannot leak) so a follow-up steering message continues with the model's
|
|
868
870
|
# half-finished work in context instead of losing it.
|
|
869
871
|
unless partial.empty?
|
|
870
|
-
message = { role: "model", content: "#{partial}\n[interrupted]", interrupted: true
|
|
872
|
+
message = { role: "model", content: "#{partial}\n[interrupted]", interrupted: true,
|
|
873
|
+
MessageTime::KEY => MessageTime.iso(MessageTime.now) }
|
|
871
874
|
# The thinking this generation streamed goes with it (the chat loop's
|
|
872
875
|
# with_thinking key, which MessageParts reads and the prompt doesn't):
|
|
873
876
|
# without it, reloading the cut step shows the empty answer it isn't.
|
|
@@ -1088,16 +1091,18 @@ module Samagotchi
|
|
|
1088
1091
|
|
|
1089
1092
|
# +keep_thought_from+: model messages from this index on keep their
|
|
1090
1093
|
# thought blocks (a resumed run's); nil strips every one.
|
|
1094
|
+
# A model message is rebuilt from an allow-list: `display`,
|
|
1095
|
+
# `display_refs` and its wall time (MessageTime::KEY) ride along so the
|
|
1096
|
+
# stored conversation keeps them; the prompt formatter never reads them
|
|
1097
|
+
# (AnswerDisplay, AnswerRefs, MessageTime).
|
|
1091
1098
|
def sanitize_history(messages, keep_thought_from: nil)
|
|
1092
1099
|
messages.each_with_index.map do |m, index|
|
|
1093
1100
|
if m[:role] == "model" && keep_thought_from && index >= keep_thought_from
|
|
1094
|
-
{ role: m[:role], content: m[:content].to_s, display: m[:display], display_refs: m[:display_refs]
|
|
1101
|
+
{ role: m[:role], content: m[:content].to_s, display: m[:display], display_refs: m[:display_refs],
|
|
1102
|
+
MessageTime::KEY => m[MessageTime::KEY] }.compact
|
|
1095
1103
|
elsif m[:role] == "model"
|
|
1096
|
-
# `display` and `display_refs` ride along so the stored
|
|
1097
|
-
# conversation keeps them; the prompt formatter never reads them
|
|
1098
|
-
# (AnswerDisplay, AnswerRefs).
|
|
1099
1104
|
{ role: m[:role], content: strip_thought_blocks(m[:content].to_s), display: m[:display],
|
|
1100
|
-
display_refs: m[:display_refs] }.compact
|
|
1105
|
+
display_refs: m[:display_refs], MessageTime::KEY => m[MessageTime::KEY] }.compact
|
|
1101
1106
|
else
|
|
1102
1107
|
m.dup
|
|
1103
1108
|
end
|
|
@@ -30,6 +30,7 @@ require_relative "../empty_answer_retry"
|
|
|
30
30
|
require_relative "../steer"
|
|
31
31
|
require_relative "../reasoning_history"
|
|
32
32
|
require_relative "../turn_note"
|
|
33
|
+
require_relative "../message_time"
|
|
33
34
|
require_relative "../thinking"
|
|
34
35
|
|
|
35
36
|
module Samagotchi
|
|
@@ -432,7 +433,8 @@ module Samagotchi
|
|
|
432
433
|
# note's marker (TurnNote.empty, never sent), and an answer's display
|
|
433
434
|
# and display_refs (AnswerDisplay, AnswerRefs; never sent), a
|
|
434
435
|
# find_tools result's loaded names and a tool change's TOOL_CHANGE
|
|
435
|
-
# (sent only to a wire that loads tools)
|
|
436
|
+
# (sent only to a wire that loads tools), and an entry's wall time
|
|
437
|
+
# (MessageTime::KEY, display-only, never sent).
|
|
436
438
|
def plain(conversation)
|
|
437
439
|
conversation.map do |entry|
|
|
438
440
|
content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
|
|
@@ -454,6 +456,7 @@ module Samagotchi
|
|
|
454
456
|
Steer::AUTHOR_KEYS.each { |key| message[key] = entry[key] if entry[key] }
|
|
455
457
|
message[ToolResponse::LOADED] = entry[ToolResponse::LOADED] if entry[ToolResponse::LOADED].is_a?(Array)
|
|
456
458
|
message[TOOL_CHANGE] = entry[TOOL_CHANGE] if entry[TOOL_CHANGE].is_a?(Hash)
|
|
459
|
+
message[MessageTime::KEY] = entry[MessageTime::KEY] if entry[MessageTime::KEY]
|
|
457
460
|
message
|
|
458
461
|
end
|
|
459
462
|
end
|
|
@@ -704,7 +707,10 @@ module Samagotchi
|
|
|
704
707
|
# (reasoning_history). A wire that sends its blocks back keeps them
|
|
705
708
|
# as +anthropic_content+ (ChatResponse#provider_content: string
|
|
706
709
|
# keys, as received, so a resumed session sends the same bytes).
|
|
710
|
+
# Every reply built from a response comes through here, so it is
|
|
711
|
+
# also where a reply gets its wall time (MessageTime::KEY).
|
|
707
712
|
def with_thinking(message, response)
|
|
713
|
+
MessageTime.stamp(message, MessageTime.now)
|
|
708
714
|
reasoning = response.reasoning.to_s
|
|
709
715
|
message = message.merge(thinking: reasoning) unless reasoning.strip.empty?
|
|
710
716
|
blocks = response.provider_content
|
|
@@ -1038,7 +1044,8 @@ module Samagotchi
|
|
|
1038
1044
|
emit(type: :generation_cancelled, iteration: iteration, reason: reason, stopped_by: @cancel_controller&.stopped_by)
|
|
1039
1045
|
visible = @loop.strip_model_thought(partial.to_s).strip
|
|
1040
1046
|
unless visible.empty?
|
|
1041
|
-
message = { role: "model", content: "#{visible}\n[interrupted]", interrupted: true
|
|
1047
|
+
message = { role: "model", content: "#{visible}\n[interrupted]", interrupted: true,
|
|
1048
|
+
MessageTime::KEY => MessageTime.iso(MessageTime.now) }
|
|
1042
1049
|
thought = thinking.to_s
|
|
1043
1050
|
message[:thinking] = thought unless thought.strip.empty?
|
|
1044
1051
|
@conversation << message
|