samagotchi 0.110.0 → 0.111.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -1
  3. data/docs/architecture.md +8 -5
  4. data/docs/cli.md +3 -0
  5. data/docs/configuration.md +43 -0
  6. data/docs/internals/engine.md +39 -10
  7. data/docs/internals/participants.md +1 -1
  8. data/docs/internals/remote-sessions.md +3 -3
  9. data/docs/internals/web-push.md +200 -0
  10. data/docs/internals/working-set.md +7 -6
  11. data/docs/sessions.md +1 -1
  12. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  13. data/lib/samagotchi/config.rb +4 -0
  14. data/lib/samagotchi/engine.rb +146 -490
  15. data/lib/samagotchi/engine_snapshots.rb +223 -0
  16. data/lib/samagotchi/event_fanout.rb +236 -0
  17. data/lib/samagotchi/model_binding.rb +138 -0
  18. data/lib/samagotchi/send_command/wait_target.rb +10 -3
  19. data/lib/samagotchi/send_command.rb +2 -1
  20. data/lib/samagotchi/session.rb +12 -3
  21. data/lib/samagotchi/session_access/remote.rb +2 -1
  22. data/lib/samagotchi/session_access.rb +7 -3
  23. data/lib/samagotchi/session_manager.rb +5 -0
  24. data/lib/samagotchi/turn_endings.rb +137 -0
  25. data/lib/samagotchi/version.rb +1 -1
  26. data/lib/samagotchi/web/app.rb +27 -1377
  27. data/lib/samagotchi/web/grants.rb +3 -12
  28. data/lib/samagotchi/web/message_display.rb +274 -0
  29. data/lib/samagotchi/web/private_files.rb +31 -0
  30. data/lib/samagotchi/web/public/app.js +30 -6
  31. data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
  32. data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
  33. data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
  34. data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
  35. data/lib/samagotchi/web/public/index.html +4 -0
  36. data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
  37. data/lib/samagotchi/web/public/notifications.js +137 -3
  38. data/lib/samagotchi/web/public/notify.js +1 -1
  39. data/lib/samagotchi/web/public/push.js +95 -0
  40. data/lib/samagotchi/web/public/stage_view.js +8 -0
  41. data/lib/samagotchi/web/public/sw.js +101 -0
  42. data/lib/samagotchi/web/public/turn_events.js +22 -0
  43. data/lib/samagotchi/web/push/attention.rb +105 -0
  44. data/lib/samagotchi/web/push/client.rb +103 -0
  45. data/lib/samagotchi/web/push/crypto.rb +126 -0
  46. data/lib/samagotchi/web/push/notifier.rb +230 -0
  47. data/lib/samagotchi/web/push/store.rb +285 -0
  48. data/lib/samagotchi/web/push.rb +73 -0
  49. data/lib/samagotchi/web/push_routes.rb +99 -0
  50. data/lib/samagotchi/web/server.rb +26 -2
  51. data/lib/samagotchi/web/session_control.rb +441 -0
  52. data/lib/samagotchi/web/session_create.rb +384 -0
  53. data/lib/samagotchi/web/session_show.rb +411 -0
  54. data/lib/samagotchi/worker.rb +3 -2
  55. metadata +25 -1
@@ -0,0 +1,411 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time" # Time.iso8601
4
+ require "uri"
5
+
6
+ require_relative "../analytics_file"
7
+ require_relative "../answer_refs"
8
+ require_relative "../answer_tail"
9
+ require_relative "../output_formatter"
10
+ require_relative "../question_asked"
11
+ require_relative "../session_commands"
12
+ require_relative "../session_metrics"
13
+ require_relative "../session_setting"
14
+
15
+ module Samagotchi
16
+ module Web
17
+ # GET /api/sessions/:id, the page's session read, and its light re-reads
18
+ # (?cards=1, ?tail=1, ?timing=1; ?brief=1 is SessionAccessRoutes'
19
+ # handle_brief_read), GET /api/sessions/:id/output, and the timing
20
+ # payload they carry. Included in App: its ROUTES name the handlers, and
21
+ # its helpers (json_response, error_response, bridge_get_json,
22
+ # session_owner, session_to_json, …) and ivars (@manager,
23
+ # @session_class, @state_dir, @markdown_renderer) answer. A participant's
24
+ # read goes to ParticipantAccess (participant_show). See
25
+ # docs/architecture.md (chi web's routes) and docs/internals/working-set.md.
26
+ module SessionShow
27
+ private
28
+
29
+ def handle_show(req, id)
30
+ return participant_show(id) unless principal(req).owner?
31
+
32
+ # The page's light re-reads: never the whole conversation.
33
+ return handle_cards_read(id) if req.params["cards"] == "1"
34
+ return handle_tail_read(req, id) if req.params["tail"] == "1"
35
+ return handle_timing_read(id) if req.params["timing"] == "1"
36
+ return handle_brief_read(id) if req.params["brief"] == "1"
37
+
38
+ session = @session_class.load(id, state_dir: default_state_dir)
39
+ # Read-only preview: selecting a session never spawns a worker.
40
+ # The worker is woken only on POST /turn (handle_turn) via
41
+ # SessionManager.resume_session + write_turn_input. This avoids
42
+ # replaying last_prompt and spamming workers on preview scrub.
43
+ # last_event_seq is nil when no live bridge, so the frontend stays
44
+ # silent until the first Send.
45
+ #
46
+ # A live worker's snapshot is the truth (the session file lags its
47
+ # Engine): the messages, the turn in progress and the prompts queued
48
+ # behind it, all at one event_seq the client streams on from.
49
+ live = bridge_get_json(id, "snapshot")
50
+ turn_snapshot = live && live["snapshot"]
51
+ snapshot = live && live["session_state_snapshot"]
52
+ last_event_seq = live_or_saved(snapshot, "event_seq") { bridge_event_seq(id) }
53
+ current_turn = turn_snapshot && turn_snapshot["current_turn"]
54
+ owner = session_owner(id)
55
+ pending = shown_pending_question(session, turn_snapshot, owner)
56
+ # What the worker's server said it served for that model (after a turn);
57
+ # without a worker, what the session's analytics.json last saved.
58
+ persisted = snapshot ? nil : read_analytics(id)
59
+ session_json = shown_session_json(session, snapshot, owner, persisted)
60
+ raw_messages = live_or_saved(turn_snapshot, "messages") { session.messages }
61
+ timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
62
+ json_response(200, {
63
+ session: session_json,
64
+ history: read_history(id),
65
+ messages: messages_for_display(raw_messages, parts: req.params["parts"] == "1",
66
+ cwd: session.working_directory),
67
+ failed_turn: failed_turn_for(session, raw_messages),
68
+ current_turn: current_turn,
69
+ queued: snapshot_list(turn_snapshot, "queued"),
70
+ # Commands waiting for the running turn's end (their bubbles).
71
+ queued_commands: snapshot_list(turn_snapshot, "queued_commands"),
72
+ recap: turn_snapshot && turn_snapshot["recap"],
73
+ saved_recap: saved_recap_for(id, session, turn_snapshot),
74
+ continue_offer: turn_snapshot && turn_snapshot["continue_offer"],
75
+ guardrail_warning: turn_snapshot && turn_snapshot["guardrail_warning"],
76
+ plugin_warning: turn_snapshot && turn_snapshot["plugin_warning"],
77
+ # Plugins' slow setup still running (chi.init): the page's init row.
78
+ init_tasks: snapshot_list(turn_snapshot, "init_tasks"),
79
+ cards: cards_for_display(live_or_saved(turn_snapshot, "cards") { saved_cards(id) }, cwd: session.working_directory),
80
+ # The composer's / autocomplete; the built-ins until a worker
81
+ # names its plugins' too.
82
+ commands: turn_snapshot&.fetch("commands", nil) || SessionCommands.builtin_registry.listing,
83
+ markdown_warning: @markdown_renderer.warning,
84
+ **viewer_capabilities(req),
85
+ pending_question: pending,
86
+ last_event_seq: last_event_seq,
87
+ # The stream cursor `<seq>-<epoch>`: a later worker resets it
88
+ # instead of taking the seq as its own. Only the snapshot has it.
89
+ last_event_id: turn_snapshot && turn_snapshot["event_id"],
90
+ timing: timing
91
+ })
92
+ end
93
+
94
+ # A list in the worker's turn +snapshot+ (queued prompts, commands,
95
+ # init tasks); [] without a worker.
96
+ def snapshot_list(snapshot, key) = Array(snapshot&.[](key))
97
+
98
+ # A live worker's +key+ as its reading (+live+: a snapshot, a tail)
99
+ # says it, nil included; without a worker (+live+ nil) the block's,
100
+ # the server's own reading of the saved session. A nil value from a
101
+ # worker is not a missing one (contrast SessionSetting#snapshot_value's
102
+ # :or).
103
+ def live_or_saved(live, key)
104
+ live ? live[key] : yield
105
+ end
106
+
107
+ # The file's question only while a worker owns the session: one a
108
+ # dead worker saved can't be answered (its turn is gone), nor a chi
109
+ # REPL's (it shares nothing with the web).
110
+ # A question between turns (the step-limit one) is the snapshot's own.
111
+ # Its refs (the asking reply's, QuestionAsked) are checked again.
112
+ def shown_pending_question(session, turn_snapshot, owner)
113
+ pending = if turn_snapshot
114
+ current_turn = turn_snapshot["current_turn"]
115
+ current_turn ? current_turn["pending_question"] : turn_snapshot["pending_question"]
116
+ elsif owner&.worker?
117
+ session.pending_question&.to_h
118
+ end
119
+ with_question_refs(pending, cwd: session.working_directory)
120
+ end
121
+
122
+ # A pending question (a snapshot's, the session file's or a saved
123
+ # card's) with its refs checked again, under a question's caps
124
+ # (QuestionAsked): a stored file is never trusted, and what isn't
125
+ # valid any more is dropped. The question as it was without refs.
126
+ def with_question_refs(pending, cwd:)
127
+ return pending unless pending.is_a?(Hash)
128
+
129
+ key = [:refs, "refs"].find { |name| pending.key?(name) } or return pending
130
+ refs = Samagotchi::AnswerRefs.for_display(pending[key], cwd: cwd, max_refs: Samagotchi::QuestionAsked::MAX_REFS,
131
+ max_bytes: Samagotchi::QuestionAsked::MAX_BYTES)
132
+ refs.empty? ? pending.except(key) : pending.merge(key => refs)
133
+ end
134
+
135
+ # The full show's session part: the file's, with what a live worker's
136
+ # state +snapshot+ knows first (its model, status, served model,
137
+ # prompt notes and chips); without one, the served model from
138
+ # +persisted+ (analytics.json). Sets a worker's model on +session+:
139
+ # a /model in the worker changes it before the file catches up.
140
+ def shown_session_json(session, snapshot, owner, persisted)
141
+ session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
142
+ session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
143
+ if snapshot
144
+ session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"],
145
+ served_expected_by: snapshot["served_expected_by"])
146
+ # The model notes its prompt carries now (a /model there changes them).
147
+ session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
148
+ else
149
+ session_json = session_json.merge(saved_served_model(session, persisted))
150
+ end
151
+ # The per-session settings' chips (the info bar's llm ctx, think,
152
+ # reasoning and tools): the worker's, else worked out here from the
153
+ # session file (SessionSetting#snapshot_value, #saved_summary).
154
+ SessionSetting::ALL.each do |setting|
155
+ chip = setting.snapshot_value(snapshot) { setting.saved_summary(session, registry: host_registry) }
156
+ session_json = session_json.merge(setting.key => chip)
157
+ end
158
+ session_json
159
+ end
160
+
161
+ # ?cards=1: the page's re-read when a card arrives, for its rendered
162
+ # body (the stream carries the card's plain text only). The worker's
163
+ # cards from its light GET tail, else the saved ones; the session file
164
+ # is only stat'ed (the 404), never parsed.
165
+ def handle_cards_read(id)
166
+ return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
167
+
168
+ tail = live_tail(id)
169
+ json_response(200, { cards: cards_for_display(live_or_saved(tail, "cards") { saved_cards(id) }) })
170
+ end
171
+
172
+ # ?tail=1: the page's re-read at the end of a turn wants the final
173
+ # answer's rendered markdown, the status and the timing, not the whole
174
+ # history again (nor a render of every earlier answer). Live: the
175
+ # worker's GET tail alone (no session file read); else the file. The
176
+ # session part is what the page reads of it (id, status, used memory
177
+ # names, the model notes its prompt carried: the first turn's build or
178
+ # a /model records them). ?turn_id=: that turn's answer (a queued
179
+ # turn's page re-reads after the next one started). The timing's records
180
+ # are the newest turn's (#recent_timing), which the page merges by id
181
+ # (the page still sends ?recent=1, which is accepted and not needed).
182
+ def handle_tail_read(req, id)
183
+ return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
184
+
185
+ turn_id = req.params["turn_id"].to_s.strip
186
+ turn_id = nil if turn_id.empty?
187
+ tail = live_tail(id, turn_id: turn_id)
188
+ state = tail && tail["session_state_snapshot"]
189
+ state = nil unless state.is_a?(Hash)
190
+ if state
191
+ status, used, notes = state.values_at("status", "used_memory_names", "prompt_notes")
192
+ answer = tail["answer"]
193
+ cwd = tail_working_directory(id, tail, answer)
194
+ answer = without_file_refs(answer) unless cwd
195
+ messages = last_assistant_for_display([answer].compact, cwd: cwd)
196
+ else
197
+ session = @session_class.load(id, state_dir: default_state_dir)
198
+ status = displayed_status(session)
199
+ used = session.used_memory_names
200
+ notes = session.prompt_notes.map(&:to_h)
201
+ messages = last_assistant_for_display(session.messages, turn_id: turn_id, cwd: session.working_directory)
202
+ end
203
+ session_json = { id: id, status: status, used_memory_names: Array(used), prompt_notes: Array(notes) }
204
+ live_metrics = state && state["metrics"]
205
+ timing = recent_timing(id, live_metrics)
206
+ json_response(200, {
207
+ tail: true,
208
+ session: session_json,
209
+ messages: messages,
210
+ markdown_warning: @markdown_renderer.warning,
211
+ **viewer_capabilities(req),
212
+ timing: timing
213
+ })
214
+ end
215
+
216
+ # The folder the live tail's answer's file refs must stay under: the
217
+ # worker's (its tail's working_directory), else the hub's session list
218
+ # entry's (a worker from before the field, or read through its
219
+ # snapshot), else, only when the answer has refs to check, the session
220
+ # file's, as the full read takes it. nil when none is known: the
221
+ # caller drops the file refs rather than show them unchecked.
222
+ # @return [String, nil]
223
+ def tail_working_directory(id, tail, answer)
224
+ known = ->(dir) { dir.is_a?(String) && !dir.empty? }
225
+ cwd = tail["working_directory"]
226
+ cwd = @hub&.working_directory(id) unless known.call(cwd)
227
+ return cwd if known.call(cwd)
228
+ return nil unless answer.is_a?(Hash) && (answer[AnswerRefs::KEY.to_s] || answer[AnswerRefs::KEY])
229
+
230
+ cwd = @session_class.load(id, state_dir: default_state_dir).working_directory
231
+ known.call(cwd) ? cwd : nil
232
+ rescue ArgumentError, KeyError, SystemCallError
233
+ nil
234
+ end
235
+
236
+ # ?timing=1: the whole timing (every record, analytics.json merged with
237
+ # the live ones) and turn_count, when the page's merge comes up short.
238
+ # The worker's GET state, never its messages.
239
+ def handle_timing_read(id)
240
+ return error_response(404, "not_found", "Session not found: #{id}") unless session_exists?(id)
241
+
242
+ state = bridge_get_json(id, "state")
243
+ json_response(200, { timing: timing_payload(id, live_metrics: state && state["session_state_snapshot"]&.fetch("metrics", nil)) })
244
+ end
245
+
246
+ def session_exists?(id)
247
+ @session_class.exist?(id, state_dir: default_state_dir)
248
+ rescue ArgumentError
249
+ false
250
+ end
251
+
252
+ # The worker's light read (Bridge GET tail): {"session_state_snapshot",
253
+ # "working_directory", "answer", "cards", "event_seq", "event_id"}. A
254
+ # worker from before the route answers 404 not_found: its GET snapshot,
255
+ # in the same shape (the answer by AnswerTail; no working_directory). Anything else (no worker, a refused connect, a
256
+ # 500, a timeout) is nil: the caller reads the disk, with no second
257
+ # try at a worker that failed once.
258
+ # @return [Hash, nil]
259
+ def live_tail(id, turn_id: nil)
260
+ status, body = bridge_get(id, turn_id ? "tail?turn_id=#{URI.encode_www_form_component(turn_id)}" : "tail")
261
+ return body if status == 200 && body.is_a?(Hash)
262
+ return nil unless status == 404 && body.is_a?(Hash) && body["error"] == "not_found"
263
+
264
+ status, live = bridge_get(id, "snapshot")
265
+ return nil unless status == 200 && live.is_a?(Hash) && live["snapshot"].is_a?(Hash)
266
+
267
+ snap = live["snapshot"]
268
+ { "session_state_snapshot" => live["session_state_snapshot"],
269
+ "answer" => AnswerTail.find(snap["messages"], turn_id: turn_id),
270
+ "cards" => snap["cards"], "event_seq" => snap["event_seq"], "event_id" => snap["event_id"] }
271
+ end
272
+
273
+ def handle_output(req, id)
274
+ # Query ?since= may be an ISO8601 time or numeric mtime; we treat it as Time parse.
275
+ since_param = req.params["since"]
276
+ since_time = parse_since(since_param)
277
+ session = @session_class.load(id, state_dir: default_state_dir)
278
+ raw = @manager.read_responses(id, since_time: since_time, state_dir: @state_dir)
279
+ # Strip wire tokens server-side so the client never sees <|tool_call> etc.
280
+ rendered = raw.map { |chunk| OutputFormatter.strip(chunk) }.reject(&:empty?)
281
+ json_response(200, { session_id: session.id, output: rendered })
282
+ end
283
+
284
+ # The whole timing: analytics.json's records merged with a live
285
+ # worker's (by id), and turn_count, the finished turns (#turn_count).
286
+ # +persisted+: the session's analytics.json when the caller read it.
287
+ def timing_payload(session_id, live_metrics: nil, persisted: nil)
288
+ persisted ||= read_analytics(session_id)
289
+ live = live_metrics.is_a?(Hash)
290
+ source = if live
291
+ persisted.merge(live_metrics).merge(
292
+ "started_at" => persisted["started_at"] || live_metrics["started_at"],
293
+ "turn_records" => merge_timing_records(without_running_turn(persisted["turn_records"], live_metrics),
294
+ live_metrics["turn_records"]),
295
+ "tool_records" => merge_timing_records(persisted["tool_records"], live_metrics["tool_records"])
296
+ )
297
+ else
298
+ persisted
299
+ end
300
+ turns = Array(source["turn_records"])
301
+ timing_from(source, turns, Array(source["tool_records"]), live: live,
302
+ turn_count: (live && turn_count(live_metrics)) || turns.size)
303
+ end
304
+
305
+ # The timing a ?tail=1 read sends: a live worker's recent
306
+ # records as its metrics hold them (the newest finished turn's and
307
+ # the unsaved ones, the running turn's finished calls), no
308
+ # analytics.json read; with no worker the disk's, trimmed to the
309
+ # newest turn. turn_count tells the page whether its merge missed one
310
+ # (it then reads ?timing=1).
311
+ def recent_timing(session_id, live_metrics)
312
+ if live_metrics.is_a?(Hash)
313
+ count = turn_count(live_metrics)
314
+ # A metrics hash without the count: the whole timing.
315
+ return timing_payload(session_id, live_metrics: live_metrics) unless count
316
+
317
+ turns = Array(live_metrics["turn_records"])
318
+ tools = Array(live_metrics["tool_records"])
319
+ # An older worker's metrics hold every record (before they held the
320
+ # recent ones only, never more than LIVE_RECORDS_CAP turns).
321
+ turns, tools = newest_turn_records(turns, tools, live_metrics["active_turn"]) if turns.size > SessionMetrics::LIVE_RECORDS_CAP
322
+ return timing_from(live_metrics, turns, tools, live: true, turn_count: count)
323
+ end
324
+
325
+ persisted = read_analytics(session_id)
326
+ all = Array(persisted["turn_records"])
327
+ turns, tools = newest_turn_records(all, Array(persisted["tool_records"]), nil)
328
+ timing_from(persisted, turns, tools, live: false, turn_count: all.size)
329
+ end
330
+
331
+ # The finished turns a live worker counts: its metrics' turns (the
332
+ # loaded history, its own and the running one) less the running one.
333
+ # @return [Integer, nil]
334
+ def turn_count(metrics)
335
+ turns = metrics["turns"]
336
+ return nil unless turns.is_a?(Integer)
337
+
338
+ [turns - (metrics["active_turn"] ? 1 : 0), 0].max
339
+ end
340
+
341
+ # The newest turn record and its tool records (and the running turn's
342
+ # finished calls).
343
+ def newest_turn_records(turns, tools, active_turn)
344
+ kept = turns.last(1)
345
+ ids = kept.filter_map { |r| r["id"] if r.is_a?(Hash) }
346
+ ids << active_turn["id"] if active_turn.is_a?(Hash) && active_turn["id"]
347
+ [kept, tools.select { |r| r.is_a?(Hash) && ids.include?(r["turn_id"]) }]
348
+ end
349
+
350
+ def timing_from(source, turn_records, tool_records, live:, turn_count:)
351
+ started_at = source["started_at"]
352
+ last_activity_at = source["last_activity_at"]
353
+ {
354
+ started_at: started_at,
355
+ last_activity_at: last_activity_at,
356
+ session_duration_ms: session_duration_ms(started_at, last_activity_at, active: live),
357
+ turn_records: turn_records,
358
+ tool_records: tool_records,
359
+ turn_count: turn_count,
360
+ active_turn: source["active_turn"],
361
+ active_tools: Array(source["active_tools"]),
362
+ # The context last seen and the token sums, for the ctx meter
363
+ # before the next turn streams (live over saved).
364
+ context: source["context"],
365
+ tokens: source["tokens"],
366
+ # The tokens, cost and speeds per model (SessionMetrics'
367
+ # model_segments; none with one model, or from an older file).
368
+ model_segments: Array(source["model_segments"]),
369
+ # The memory indexes the session's prompt holds (the ctx tooltip).
370
+ memory_index: source["memory_index"],
371
+ # The side requests (recap, /btw, ctx.ask_model), apart from the
372
+ # tokens: the ctx tooltip's own line (nil without any).
373
+ side: source["side"]
374
+ }
375
+ end
376
+
377
+ def read_analytics(session_id)
378
+ AnalyticsFile.read(@session_class.session_dir(session_id, state_dir: default_state_dir)) || {}
379
+ end
380
+
381
+ # The saved turn records without the one the live worker is running:
382
+ # its turn checkpoint saves that turn's record so far as a killed
383
+ # turn's (canceled, worker_stopped, SessionMetrics#persist's
384
+ # running_turn), which is only true once the worker is gone.
385
+ def without_running_turn(records, live_metrics)
386
+ active = live_metrics["active_turn"]
387
+ id = active.is_a?(Hash) ? active["id"] || active[:id] : nil
388
+ return records if id.nil?
389
+
390
+ Array(records).reject { |record| record.is_a?(Hash) && (record["id"] || record[:id]) == id }
391
+ end
392
+
393
+ def merge_timing_records(persisted, live)
394
+ (Array(persisted) + Array(live)).each_with_object({}) do |record, records|
395
+ next unless record.is_a?(Hash)
396
+
397
+ id = record["id"] || record[:id]
398
+ records[id] = record if id
399
+ end.values
400
+ end
401
+
402
+ def session_duration_ms(started_at, last_activity_at, active:)
403
+ started = Time.iso8601(started_at.to_s)
404
+ finished = active ? Time.now : Time.iso8601(last_activity_at.to_s)
405
+ [((finished - started) * 1000).round, 0].max
406
+ rescue ArgumentError
407
+ nil
408
+ end
409
+ end
410
+ end
411
+ end
@@ -607,10 +607,11 @@ module Samagotchi
607
607
  end
608
608
 
609
609
  # The first prompt (spawn_session's): a delegate child's task, which is
610
- # its parent's although no client sent it.
610
+ # its parent's although no client sent it. Its turn carries the id
611
+ # spawn_session gave it, so a wait for it (--new --wait) names it.
611
612
  def run_initial_prompt(prompt)
612
613
  @initial_turn = true
613
- run_prompt(WorkerInbound::Prompt.new(text: prompt))
614
+ run_prompt(WorkerInbound::Prompt.new(text: prompt, origin: TurnOrigin.new(enqueued_id: @session.first_turn_id)))
614
615
  ensure
615
616
  @initial_turn = false
616
617
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.110.0
4
+ version: 0.111.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -164,6 +164,7 @@ files:
164
164
  - docs/internals/prompt-caching.md
165
165
  - docs/internals/remote-sessions.md
166
166
  - docs/internals/tool-guardrails.md
167
+ - docs/internals/web-push.md
167
168
  - docs/internals/working-set.md
168
169
  - docs/memory.md
169
170
  - docs/plugins.md
@@ -325,6 +326,8 @@ files:
325
326
  - lib/samagotchi/empty_answer_retry.rb
326
327
  - lib/samagotchi/engine.rb
327
328
  - lib/samagotchi/engine_peers.rb
329
+ - lib/samagotchi/engine_snapshots.rb
330
+ - lib/samagotchi/event_fanout.rb
328
331
  - lib/samagotchi/event_routes.rb
329
332
  - lib/samagotchi/events.rb
330
333
  - lib/samagotchi/extension_load.rb
@@ -450,6 +453,7 @@ files:
450
453
  - lib/samagotchi/memory_paths.rb
451
454
  - lib/samagotchi/message_parts.rb
452
455
  - lib/samagotchi/message_time.rb
456
+ - lib/samagotchi/model_binding.rb
453
457
  - lib/samagotchi/model_catalog.rb
454
458
  - lib/samagotchi/model_list_store.rb
455
459
  - lib/samagotchi/model_match.rb
@@ -681,6 +685,7 @@ files:
681
685
  - lib/samagotchi/tools/web_fetch.rb
682
686
  - lib/samagotchi/tools/write.rb
683
687
  - lib/samagotchi/turn_checkpoint.rb
688
+ - lib/samagotchi/turn_endings.rb
684
689
  - lib/samagotchi/turn_flow.rb
685
690
  - lib/samagotchi/turn_note.rb
686
691
  - lib/samagotchi/turn_origin.rb
@@ -702,12 +707,14 @@ files:
702
707
  - lib/samagotchi/web/lan.rb
703
708
  - lib/samagotchi/web/link_codes.rb
704
709
  - lib/samagotchi/web/markdown_renderer.rb
710
+ - lib/samagotchi/web/message_display.rb
705
711
  - lib/samagotchi/web/participant_access.rb
706
712
  - lib/samagotchi/web/participant_review.rb
707
713
  - lib/samagotchi/web/participant_stream_filter.rb
708
714
  - lib/samagotchi/web/participant_view.rb
709
715
  - lib/samagotchi/web/participants_admin.rb
710
716
  - lib/samagotchi/web/principal.rb
717
+ - lib/samagotchi/web/private_files.rb
711
718
  - lib/samagotchi/web/proxy_gate.rb
712
719
  - lib/samagotchi/web/public/activity.js
713
720
  - lib/samagotchi/web/public/annotate_actions.js
@@ -716,6 +723,7 @@ files:
716
723
  - lib/samagotchi/web/public/annotations.js
717
724
  - lib/samagotchi/web/public/answer_refs.js
718
725
  - lib/samagotchi/web/public/app.js
726
+ - lib/samagotchi/web/public/apple-touch-icon.png
719
727
  - lib/samagotchi/web/public/auth_lost.js
720
728
  - lib/samagotchi/web/public/bubbles.js
721
729
  - lib/samagotchi/web/public/card.js
@@ -742,6 +750,9 @@ files:
742
750
  - lib/samagotchi/web/public/favicon.svg
743
751
  - lib/samagotchi/web/public/format.js
744
752
  - lib/samagotchi/web/public/hold.js
753
+ - lib/samagotchi/web/public/icons/chi-180.png
754
+ - lib/samagotchi/web/public/icons/chi-192.png
755
+ - lib/samagotchi/web/public/icons/chi-512.png
745
756
  - lib/samagotchi/web/public/images.js
746
757
  - lib/samagotchi/web/public/index.html
747
758
  - lib/samagotchi/web/public/info_bar.js
@@ -754,6 +765,7 @@ files:
754
765
  - lib/samagotchi/web/public/live_timing.js
755
766
  - lib/samagotchi/web/public/live_turn.js
756
767
  - lib/samagotchi/web/public/llm_context_chip.js
768
+ - lib/samagotchi/web/public/manifest.webmanifest
757
769
  - lib/samagotchi/web/public/markdown_place.js
758
770
  - lib/samagotchi/web/public/model_pick.js
759
771
  - lib/samagotchi/web/public/model_picker.js
@@ -767,6 +779,7 @@ files:
767
779
  - lib/samagotchi/web/public/participants_panel.js
768
780
  - lib/samagotchi/web/public/plain_line.js
769
781
  - lib/samagotchi/web/public/prompt_history.js
782
+ - lib/samagotchi/web/public/push.js
770
783
  - lib/samagotchi/web/public/question_card.js
771
784
  - lib/samagotchi/web/public/question_cards.js
772
785
  - lib/samagotchi/web/public/read_view.js
@@ -798,6 +811,7 @@ files:
798
811
  - lib/samagotchi/web/public/stream_handlers.js
799
812
  - lib/samagotchi/web/public/strip.js
800
813
  - lib/samagotchi/web/public/strip_grip.js
814
+ - lib/samagotchi/web/public/sw.js
801
815
  - lib/samagotchi/web/public/tally.js
802
816
  - lib/samagotchi/web/public/task_stop.js
803
817
  - lib/samagotchi/web/public/thinking_chip.js
@@ -824,11 +838,21 @@ files:
824
838
  - lib/samagotchi/web/public/working_set_rail.js
825
839
  - lib/samagotchi/web/public/working_set_view.js
826
840
  - lib/samagotchi/web/public_url.rb
841
+ - lib/samagotchi/web/push.rb
842
+ - lib/samagotchi/web/push/attention.rb
843
+ - lib/samagotchi/web/push/client.rb
844
+ - lib/samagotchi/web/push/crypto.rb
845
+ - lib/samagotchi/web/push/notifier.rb
846
+ - lib/samagotchi/web/push/store.rb
847
+ - lib/samagotchi/web/push_routes.rb
827
848
  - lib/samagotchi/web/qr.rb
828
849
  - lib/samagotchi/web/running.rb
829
850
  - lib/samagotchi/web/server.rb
830
851
  - lib/samagotchi/web/session_access_routes.rb
852
+ - lib/samagotchi/web/session_control.rb
853
+ - lib/samagotchi/web/session_create.rb
831
854
  - lib/samagotchi/web/session_hub.rb
855
+ - lib/samagotchi/web/session_show.rb
832
856
  - lib/samagotchi/web/session_summary.rb
833
857
  - lib/samagotchi/web/token.rb
834
858
  - lib/samagotchi/web/tunnel.rb