samagotchi 0.109.0 → 0.111.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +57 -1
  3. data/bin/chi +5 -1
  4. data/docs/architecture.md +9 -6
  5. data/docs/cli.md +43 -1
  6. data/docs/configuration.md +140 -3
  7. data/docs/internals/engine.md +58 -14
  8. data/docs/internals/participants.md +7 -4
  9. data/docs/internals/prompt-caching.md +5 -0
  10. data/docs/internals/remote-sessions.md +3 -3
  11. data/docs/internals/web-push.md +200 -0
  12. data/docs/internals/working-set.md +7 -6
  13. data/docs/plugins.md +1 -1
  14. data/docs/sessions.md +4 -2
  15. data/docs/sub-agent.md +1 -1
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/config.rb +4 -0
  19. data/lib/samagotchi/context_note.rb +7 -3
  20. data/lib/samagotchi/engine.rb +166 -499
  21. data/lib/samagotchi/engine_snapshots.rb +223 -0
  22. data/lib/samagotchi/event_fanout.rb +236 -0
  23. data/lib/samagotchi/idle_recap.rb +1 -1
  24. data/lib/samagotchi/idle_reminders.rb +1 -1
  25. data/lib/samagotchi/kernel_loop.rb +12 -7
  26. data/lib/samagotchi/llm/chat_loop.rb +9 -2
  27. data/lib/samagotchi/message_time.rb +104 -0
  28. data/lib/samagotchi/model_binding.rb +138 -0
  29. data/lib/samagotchi/reminder_queue.rb +20 -3
  30. data/lib/samagotchi/reply_wait/poll.rb +39 -1
  31. data/lib/samagotchi/run_options.rb +4 -1
  32. data/lib/samagotchi/send_command/wait_target.rb +10 -3
  33. data/lib/samagotchi/send_command.rb +2 -1
  34. data/lib/samagotchi/session.rb +12 -3
  35. data/lib/samagotchi/session_access/contract.rb +1 -1
  36. data/lib/samagotchi/session_access/local.rb +11 -5
  37. data/lib/samagotchi/session_access/remote.rb +2 -1
  38. data/lib/samagotchi/session_access.rb +7 -3
  39. data/lib/samagotchi/session_inbox.rb +7 -3
  40. data/lib/samagotchi/session_manager.rb +5 -0
  41. data/lib/samagotchi/steer.rb +5 -3
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
  43. data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
  44. data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
  45. data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
  46. data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
  47. data/lib/samagotchi/terminal_ui.rb +40 -5
  48. data/lib/samagotchi/tool_activity.rb +11 -0
  49. data/lib/samagotchi/turn_checkpoint.rb +6 -1
  50. data/lib/samagotchi/turn_endings.rb +137 -0
  51. data/lib/samagotchi/version.rb +1 -1
  52. data/lib/samagotchi/web/app.rb +28 -1369
  53. data/lib/samagotchi/web/grants.rb +3 -12
  54. data/lib/samagotchi/web/message_display.rb +274 -0
  55. data/lib/samagotchi/web/participant_view.rb +12 -5
  56. data/lib/samagotchi/web/private_files.rb +31 -0
  57. data/lib/samagotchi/web/public/app.js +41 -9
  58. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  59. data/lib/samagotchi/web/public/bubbles.js +29 -7
  60. data/lib/samagotchi/web/public/format.js +72 -6
  61. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  62. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  63. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  64. data/lib/samagotchi/web/public/index.html +6 -0
  65. data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
  66. data/lib/samagotchi/web/public/notifications.js +137 -3
  67. data/lib/samagotchi/web/public/notify.js +1 -1
  68. data/lib/samagotchi/web/public/participant.html +2 -0
  69. data/lib/samagotchi/web/public/participant.js +30 -7
  70. data/lib/samagotchi/web/public/participant_model.js +25 -2
  71. data/lib/samagotchi/web/public/push.js +95 -0
  72. data/lib/samagotchi/web/public/stage_view.js +8 -0
  73. data/lib/samagotchi/web/public/stream_handlers.js +32 -4
  74. data/lib/samagotchi/web/public/sw.js +101 -0
  75. data/lib/samagotchi/web/public/time_gaps.js +98 -0
  76. data/lib/samagotchi/web/public/tokens.css +2 -2
  77. data/lib/samagotchi/web/public/turn_events.js +25 -1
  78. data/lib/samagotchi/web/public/turn_html.js +15 -8
  79. data/lib/samagotchi/web/public/turn_view.js +8 -3
  80. data/lib/samagotchi/web/push/attention.rb +105 -0
  81. data/lib/samagotchi/web/push/client.rb +103 -0
  82. data/lib/samagotchi/web/push/crypto.rb +126 -0
  83. data/lib/samagotchi/web/push/notifier.rb +230 -0
  84. data/lib/samagotchi/web/push/store.rb +285 -0
  85. data/lib/samagotchi/web/push.rb +73 -0
  86. data/lib/samagotchi/web/push_routes.rb +99 -0
  87. data/lib/samagotchi/web/running.rb +39 -4
  88. data/lib/samagotchi/web/server.rb +112 -32
  89. data/lib/samagotchi/web/session_control.rb +441 -0
  90. data/lib/samagotchi/web/session_create.rb +384 -0
  91. data/lib/samagotchi/web/session_show.rb +411 -0
  92. data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
  93. data/lib/samagotchi/web/tunnel.rb +133 -0
  94. data/lib/samagotchi/worker.rb +3 -2
  95. metadata +30 -1
@@ -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] }.compact
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