samagotchi 0.109.0 → 0.110.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -1
  3. data/bin/chi +5 -1
  4. data/docs/architecture.md +1 -1
  5. data/docs/cli.md +40 -1
  6. data/docs/configuration.md +97 -3
  7. data/docs/internals/engine.md +19 -4
  8. data/docs/internals/participants.md +6 -3
  9. data/docs/internals/prompt-caching.md +5 -0
  10. data/docs/plugins.md +1 -1
  11. data/docs/sessions.md +3 -1
  12. data/docs/sub-agent.md +1 -1
  13. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  14. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  15. data/lib/samagotchi/context_note.rb +7 -3
  16. data/lib/samagotchi/engine.rb +20 -9
  17. data/lib/samagotchi/idle_recap.rb +1 -1
  18. data/lib/samagotchi/idle_reminders.rb +1 -1
  19. data/lib/samagotchi/kernel_loop.rb +12 -7
  20. data/lib/samagotchi/llm/chat_loop.rb +9 -2
  21. data/lib/samagotchi/message_time.rb +104 -0
  22. data/lib/samagotchi/reminder_queue.rb +20 -3
  23. data/lib/samagotchi/reply_wait/poll.rb +39 -1
  24. data/lib/samagotchi/run_options.rb +4 -1
  25. data/lib/samagotchi/session_access/contract.rb +1 -1
  26. data/lib/samagotchi/session_access/local.rb +11 -5
  27. data/lib/samagotchi/session_inbox.rb +7 -3
  28. data/lib/samagotchi/steer.rb +5 -3
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
  30. data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
  31. data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
  32. data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
  33. data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
  34. data/lib/samagotchi/terminal_ui.rb +40 -5
  35. data/lib/samagotchi/tool_activity.rb +11 -0
  36. data/lib/samagotchi/turn_checkpoint.rb +6 -1
  37. data/lib/samagotchi/version.rb +1 -1
  38. data/lib/samagotchi/web/app.rb +15 -6
  39. data/lib/samagotchi/web/participant_view.rb +12 -5
  40. data/lib/samagotchi/web/public/app.js +11 -3
  41. data/lib/samagotchi/web/public/bubbles.js +29 -7
  42. data/lib/samagotchi/web/public/format.js +72 -6
  43. data/lib/samagotchi/web/public/index.html +2 -0
  44. data/lib/samagotchi/web/public/participant.html +2 -0
  45. data/lib/samagotchi/web/public/participant.js +30 -7
  46. data/lib/samagotchi/web/public/participant_model.js +25 -2
  47. data/lib/samagotchi/web/public/stream_handlers.js +32 -4
  48. data/lib/samagotchi/web/public/time_gaps.js +98 -0
  49. data/lib/samagotchi/web/public/tokens.css +2 -2
  50. data/lib/samagotchi/web/public/turn_events.js +3 -1
  51. data/lib/samagotchi/web/public/turn_html.js +15 -8
  52. data/lib/samagotchi/web/public/turn_view.js +8 -3
  53. data/lib/samagotchi/web/running.rb +39 -4
  54. data/lib/samagotchi/web/server.rb +87 -31
  55. data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
  56. data/lib/samagotchi/web/tunnel.rb +133 -0
  57. metadata +6 -1
@@ -26,7 +26,9 @@ module Samagotchi
26
26
  # @param renderer [EventRenderer] draws cards
27
27
  # @param turn_running [#call] whether a turn runs now
28
28
  # @param quiet [Boolean] a --non-interactive run: no init lines
29
- def initialize(surface:, view:, renderer:, turn_running:, quiet: false)
29
+ # @param gaps [GapClock, nil] the session's last activity: a recap
30
+ # after a quiet gap has its divider
31
+ def initialize(surface:, view:, renderer:, turn_running:, quiet: false, gaps: nil)
30
32
  @surface = surface
31
33
  @view = view
32
34
  @renderer = renderer
@@ -34,6 +36,7 @@ module Samagotchi
34
36
  @quiet = quiet
35
37
  @cards = Queue.new
36
38
  @recap = nil
39
+ @gaps = gaps
37
40
  end
38
41
 
39
42
  attr_writer :surface
@@ -84,7 +87,7 @@ module Samagotchi
84
87
  return unless event[:type] == :recap_ready
85
88
  return if event[:recap].to_s.strip.empty? || turn_running?
86
89
 
87
- @recap = event[:recap].to_s
90
+ @recap = { text: event[:recap].to_s, at: event[:created_at] }
88
91
  end
89
92
 
90
93
  # Print a recap written while idle (main thread, at the open prompt).
@@ -93,7 +96,9 @@ module Samagotchi
93
96
  return unless recap
94
97
 
95
98
  @recap = nil
96
- @surface.commit(recap_block(recap))
99
+ line = @gaps&.divider
100
+ @surface.commit(gap_divider(line)) if line
101
+ @surface.commit(recap_block(recap[:text], at: recap[:at]))
97
102
  end
98
103
 
99
104
  # Print the cards, notices and anytime commands' output kept since the
@@ -11,6 +11,7 @@ require_relative "../memory_bundle/index_size"
11
11
  require_relative "../prompt_note"
12
12
  require_relative "../prompt_files"
13
13
  require_relative "../session_config"
14
+ require_relative "../message_time"
14
15
  require_relative "../tools/task_record"
15
16
  require_relative "model_segments_text"
16
17
 
@@ -513,19 +514,26 @@ module Samagotchi
513
514
 
514
515
  private
515
516
 
516
- # The recap shown on return: dim, one "recap>" block, noting how many
517
- # turns came since it was written.
518
- def recap_block(text, turns_since: 0)
517
+ # The recap shown on return: dim, one "recap>" block, with the local
518
+ # time it was written ("recap 14:32>") when known, noting how many
519
+ # turns came since.
520
+ # @param at [String, nil] its created_at (an ISO time, any offset)
521
+ def recap_block(text, turns_since: 0, at: nil)
519
522
  n = turns_since.to_i
520
523
  note = if n == 1 then " (before the last turn)"
521
524
  elsif n > 1 then " (before the last #{n} turns)"
522
525
  else ""
523
526
  end
527
+ clock = MessageTime.clock(at)
528
+ head = clock ? "recap #{clock}#{note}>" : "recap#{note}>"
524
529
  text.to_s.strip.lines.map(&:chomp).each_with_index.map do |line, i|
525
- paint(i.zero? ? "recap#{note}> #{line}" : line, 90)
530
+ paint(i.zero? ? "#{head} #{line}" : line, 90)
526
531
  end.join("\n")
527
532
  end
528
533
 
534
+ # A quiet gap's divider (MessageTime.gap_line, GapClock#join_line): dim.
535
+ def gap_divider(text) = paint(text, 2)
536
+
529
537
  RECAP_OFF_TEXT = "recap is off (recap: false in config.yml, or SAMAGOTCHI_RECAP_ENABLED=false; /recap on turns it on here)"
530
538
  # Off by the session's own recap.enabled (/recap off, --set recap.enabled=false).
531
539
  RECAP_SESSION_OFF_TEXT = "recap is off for this session (/recap on turns it on, /recap default goes back to config.yml)"
@@ -540,7 +548,7 @@ module Samagotchi
540
548
 
541
549
  saved = saved&.transform_keys(&:to_sym)
542
550
  lines = []
543
- lines << recap_block(saved[:text], turns_since: saved[:turns_since]) if saved
551
+ lines << recap_block(saved[:text], turns_since: saved[:turns_since], at: saved[:created_at]) if saved
544
552
  lines << case request&.to_sym
545
553
  when :started, :in_flight then "writing a recap…"
546
554
  when :nothing_new then saved ? "(nothing new since this recap)" : "no recap yet: nothing to recap"
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../message_time"
4
+
5
+ module Samagotchi
6
+ class TerminalUI
7
+ # When the session's last activity was, for both TUIs' gap dividers: a
8
+ # dim "── 14:32 · 47 min later ──" before what comes after a quiet
9
+ # MessageTime::GAP_SECONDS. Activity is a prompt (typed or another
10
+ # client's), a reminder, a context note, a recap, and a turn's end; a
11
+ # gap is measured from the end of one to the start of the next, so a
12
+ # long turn followed at once by another marks nothing.
13
+ #
14
+ # The times are this terminal's wall clock when it saw them (+now+),
15
+ # seeded at the join from the session's stamped history. Read from the
16
+ # reader thread too (a typed prompt's divider goes above its echo), so
17
+ # every read-and-mark is under one lock.
18
+ class GapClock
19
+ # @param now [#call] the wall clock (a Time); MessageTime.now, which
20
+ # the specs pin, by default
21
+ def initialize(now: -> { MessageTime.now })
22
+ @now = now
23
+ @last = nil
24
+ @lock = Mutex.new
25
+ end
26
+
27
+ # @return [String] now, as a stamp
28
+ def now_iso = MessageTime.iso(@now.call)
29
+
30
+ # Mark activity starting now.
31
+ # @param quiet [Boolean] mark only, no divider (a turn runs: its own
32
+ # steps are no gap)
33
+ # @return [String, nil] the divider's text when a gap passed since
34
+ # the last activity
35
+ def divider(quiet: false)
36
+ @lock.synchronize do
37
+ now = now_iso
38
+ line = quiet ? nil : MessageTime.gap_line(@last, now)
39
+ @last = now
40
+ line
41
+ end
42
+ end
43
+
44
+ # Mark activity (a turn's end) without a divider.
45
+ def touch = divider(quiet: true)
46
+
47
+ # Start from the session's last activity (the join, a resume): the
48
+ # newest stamp in +messages+ (Symbol- or String-keyed), else
49
+ # +fallback+ (a saved recap's created_at). Nothing known: nothing set.
50
+ # @return [String, nil] the time it started from
51
+ def seed(messages, fallback: nil)
52
+ iso = Array(messages).reverse_each.lazy.filter_map { |m| MessageTime.of(m) }.first || fallback
53
+ iso = MessageTime.iso_of(iso)
54
+ @lock.synchronize { @last = iso } if iso
55
+ iso
56
+ end
57
+
58
+ # The join's line when the last activity is a gap ago:
59
+ # "── last activity 14:32 · 47 min ago ──" (the date when the local
60
+ # day changed). Printed, it stands for the gap: the clock moves to now,
61
+ # so the next prompt marks none.
62
+ # @return [String, nil]
63
+ def join_line
64
+ @lock.synchronize do
65
+ now = now_iso
66
+ next nil unless MessageTime.gap_line(@last, now)
67
+
68
+ last = Time.iso8601(@last).localtime
69
+ same_day = last.to_date == Time.iso8601(now).localtime.to_date
70
+ time = same_day ? MessageTime.clock(@last) : MessageTime.day_clock(@last)
71
+ line = "── last activity #{time} · #{MessageTime.ago(Time.iso8601(now) - last)} ago ──"
72
+ @last = now
73
+ line
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -77,6 +77,20 @@ module Samagotchi
77
77
  Thread.current[:samagotchi_reline_echo] = was
78
78
  end
79
79
 
80
+ # Run a read (on this thread) whose submitted line may echo with a
81
+ # line above it: +above+ is called with the echoed text at Enter, a
82
+ # String goes above the echo (a gap's divider before a typed
83
+ # prompt), nil adds nothing. Only a read drawn on a screen; a read
84
+ # without echo calls nothing.
85
+ # @param above [#call] (text) -> String or nil
86
+ def echo_above(above)
87
+ was = Thread.current[:samagotchi_reline_echo_above]
88
+ Thread.current[:samagotchi_reline_echo_above] = above
89
+ yield
90
+ ensure
91
+ Thread.current[:samagotchi_reline_echo_above] = was
92
+ end
93
+
80
94
  # @api private
81
95
  attr_writer :reading
82
96
 
@@ -228,9 +242,13 @@ module Samagotchi
228
242
  def clear_screen(key) = ed_clear_screen(key)
229
243
 
230
244
  # The typed lines, or the echo's text after the prompt (#echo_as).
245
+ # A line above them when the read has one (#echo_above).
231
246
  def seam_echo_lines
232
- text = Thread.current[:samagotchi_reline_echo]&.call(@buffer_of_lines.join("\n"))
233
- text ? [Reline::Unicode.strip_non_printing_start_end(prompt_list[0]) + text] : seam_final_lines
247
+ typed = @buffer_of_lines.join("\n")
248
+ text = Thread.current[:samagotchi_reline_echo]&.call(typed)
249
+ lines = text ? [Reline::Unicode.strip_non_printing_start_end(prompt_list[0]) + text] : seam_final_lines
250
+ above = Thread.current[:samagotchi_reline_echo_above]&.call(text || typed)
251
+ above ? [above, *lines] : lines
234
252
  end
235
253
 
236
254
  # The prompt and the text as render_finished writes them: one line per
@@ -30,6 +30,7 @@ require_relative "terminal_ui/attached_view"
30
30
  require_relative "terminal_ui/between_turns"
31
31
  require_relative "terminal_ui/event_renderer"
32
32
  require_relative "terminal_ui/formatting"
33
+ require_relative "terminal_ui/gap_clock"
33
34
  require_relative "terminal_ui/input_support"
34
35
  require_relative "terminal_ui/image_input"
35
36
  require_relative "terminal_ui/plain_surface"
@@ -114,7 +115,8 @@ module Samagotchi
114
115
  # +toolkit_flags+ then)
115
116
  def initialize(prompt: nil, client: nil, host_registry: nil, profile: nil, session_id: nil, no_interrupt: false, no_default_input: false, model_name: nil, memories: [], muted_memories: [], non_interactive: false, surface: nil,
116
117
  spinner_tick_interval: AttachedView::TICK_INTERVAL, spinner_clock: nil, scratch: false, llm_context: {}, thinking: nil,
117
- appended_prompt_files: [], toolkit: nil, toolkit_flags: [], settings: nil, parent_answers: nil)
118
+ appended_prompt_files: [], toolkit: nil, toolkit_flags: [], settings: nil, parent_answers: nil,
119
+ now: -> { MessageTime.now })
118
120
  @scratch = scratch
119
121
  @parent_answers = parent_answers
120
122
  @toolkit = toolkit
@@ -190,8 +192,12 @@ module Samagotchi
190
192
  @renderer = EventRenderer.new(@view)
191
193
  # Cards, notices and a recap announced between turns, printed by the
192
194
  # main thread at the open prompt (#poll_input_with_reminder_check).
195
+ # The session's last activity, for the gap dividers (+now+: the wall
196
+ # clock, a Time).
197
+ @gaps = GapClock.new(now: now)
193
198
  @between_turns = BetweenTurns.new(surface: @surface, view: @view, renderer: @renderer,
194
- turn_running: -> { @engine.turn_running? }, quiet: @non_interactive)
199
+ turn_running: -> { @engine.turn_running? }, quiet: @non_interactive,
200
+ gaps: @gaps)
195
201
  @render_event = ->(event) { handle_stream_event(event) }
196
202
  @engine = Engine.new(
197
203
  client: client,
@@ -213,7 +219,7 @@ module Samagotchi
213
219
  # When a reminder is due, queue a synthetic turn that
214
220
  # run_assist_loop checks before read_input. The synthetic turn
215
221
  # uses an empty prompt so the agent can see
216
- # [SYSTEM: REMINDERS DUE] and act on them.
222
+ # [SYSTEM: REMINDERS DUE <local time>] and act on them.
217
223
  @engine.note_due_reminders(due_names)
218
224
  }
219
225
  }
@@ -593,7 +599,9 @@ module Samagotchi
593
599
  messages = ContextNote.with_system_head(session.messages.dup, system_message)
594
600
  banner("Resumed session: #{session.id}")
595
601
  saved = @engine.saved_recap
596
- banner(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved
602
+ banner(recap_block(saved[:text], turns_since: saved[:turns_since], at: saved[:created_at])) if saved
603
+ # The first prompt after a quiet gap has its divider.
604
+ @gaps.seed(session.messages, fallback: saved&.dig(:created_at))
597
605
  elsif @scratch
598
606
  messages = [system_message]
599
607
  banner("Scratch session: nothing is kept, it is deleted when you leave.")
@@ -721,6 +729,8 @@ module Samagotchi
721
729
  ensure
722
730
  @active_cancel_controller = nil
723
731
  @view.finish_thinking_spinner
732
+ # The turn's end is the last activity.
733
+ @gaps.touch
724
734
  end
725
735
 
726
736
  # How the REPL leaves its session once the loop ended (#run): :scratch
@@ -909,6 +919,9 @@ module Samagotchi
909
919
 
910
920
  @turn_flow.before_prompt_turn
911
921
  persist_recent_history(input)
922
+ # Off a live region (no echo seam) the divider comes here; after
923
+ # #gap_above_echo it marks none.
924
+ mark_activity
912
925
  begin
913
926
  # Engine#run_turn injects due reminders as a tail message, appends
914
927
  # the prompt, and renders through @renderer via on_event.
@@ -1051,6 +1064,7 @@ module Samagotchi
1051
1064
  sync_continue_slot(false)
1052
1065
  @surface.commit(QuestionSlot.continue_summary("(dropped: a reminder ran)", paint: method(:paint)))
1053
1066
  end
1067
+ mark_activity
1054
1068
  @surface.commit(reminder_line(names))
1055
1069
  hint = "#{reminder_line(names)} · Ctrl-C cancels it"
1056
1070
  @surface.set_slot(:hints, [color_output? ? paint(hint, 90) : hint])
@@ -1147,13 +1161,34 @@ module Samagotchi
1147
1161
  # a question's ? prompt. +prefill+ is typed in first.
1148
1162
  def read_repl_line(prompt, prefill)
1149
1163
  queue_input_prefill(prefill) if prefill
1150
- return read_prompt_line(prompt) if prompt == paint("> ", 92)
1164
+ return RelineSeam.echo_above(method(:gap_above_echo)) { read_prompt_line(prompt) } if prompt == paint("> ", 92)
1151
1165
 
1152
1166
  read = -> { with_next_input_prefill { Reline.readline(prompt, true) }&.strip }
1153
1167
  # A question's answer leaves only its summary line.
1154
1168
  prompt == paint(QUESTION_PROMPT, 33) ? RelineSeam.without_echo(&read) : read.call
1155
1169
  end
1156
1170
 
1171
+ # Activity starts now: a gap's divider first when one passed since the
1172
+ # last (none while a turn runs: a steer is no gap; none in a
1173
+ # --non-interactive run, whose stdout is the answer's alone).
1174
+ def mark_activity
1175
+ line = @gaps.divider(quiet: @non_interactive || @engine.turn_running?)
1176
+ @surface.commit(gap_divider(line)) if line
1177
+ end
1178
+
1179
+ # The divider above a typed line's echo (RelineSeam.echo_above, on the
1180
+ # reader thread): a prompt typed at the idle prompt after a quiet gap.
1181
+ # It marks the activity, so #run_input_line marks none.
1182
+ def gap_above_echo(text)
1183
+ return nil if @engine.turn_running? || @turn_flow.awaiting_continue?
1184
+
1185
+ route = ComposerRoute.call(text.to_s.strip, running: false, registry: command_registry)
1186
+ return nil unless %i[prompt deliver].include?(route.kind)
1187
+
1188
+ line = @gaps.divider
1189
+ line && gap_divider(line)
1190
+ end
1191
+
1157
1192
  # Lines come from a pipe or a file (`chi -p X </dev/null`, `echo … | chi`),
1158
1193
  # not a terminal: no prompt is restored for a retry.
1159
1194
  def piped_input? = !$stdin.tty?
@@ -43,6 +43,17 @@ module Samagotchi
43
43
  # Without a "[tool]" lead (an older or hand-made output) it stays as it is.
44
44
  def saved_result_text(output) = output.to_s.sub(SAVED_LEAD, "")
45
45
 
46
+ # A call the guardrails denied, as its saved text reads after the
47
+ # "[tool]" lead (ToolRunner#denied, Guardrails::Verdict#deny_text): a
48
+ # rule's or hook's deny, a legacy hook veto, or the user declining (or
49
+ # cancelling) the approval.
50
+ GUARDRAIL_DENIED = /\AError: (?:(?:denied|blocked) by guardrail\b|The (?:user declined this call|approval was cancelled)\b.*? It needed approval \()/m
51
+
52
+ # Whether a saved result text (#saved_result_text) is a guardrail's
53
+ # deny: "blocked" as live (its activity's status), not "error", for a
54
+ # call with no saved tool record.
55
+ def guardrail_denied?(text) = text.to_s.scrub.match?(GUARDRAIL_DENIED)
56
+
46
57
  # @param registry [Tools::Registry, nil] the session's tools
47
58
  # @param cwd [String, nil] what a file tool's title is relative to (the
48
59
  # worker runs in its session's working directory)
@@ -7,6 +7,7 @@ require_relative "atomic_file"
7
7
  require_relative "interrupted_turn"
8
8
  require_relative "last_turn"
9
9
  require_relative "log"
10
+ require_relative "message_time"
10
11
  require_relative "session"
11
12
  require_relative "session_inbox"
12
13
  require_relative "steer"
@@ -181,7 +182,9 @@ module Samagotchi
181
182
  @written_at = nil
182
183
  @started_at = Time.now.iso8601(3)
183
184
  messages = Array(@session.messages).dup
184
- messages << { role: "user", content: prompt, **author } unless prompt.to_s.empty?
185
+ # Stamped by the wall clock here, a moment before the turn's own
186
+ # stamp (Engine's Turn#wall), which the turn's first boundary saves.
187
+ messages << { role: "user", content: prompt, **author, MessageTime::KEY => MessageTime.iso(MessageTime.now) } unless prompt.to_s.empty?
185
188
  @session.turn_checkpoint = { messages: messages, marker: InterruptedTurn.new(started_at: @started_at, at: @started_at, iteration: 0,
186
189
  enqueued_id: @enqueued_id) }
187
190
  end
@@ -202,6 +205,8 @@ module Samagotchi
202
205
  return false unless conversation
203
206
 
204
207
  @boundaries += 1
208
+ # The merge's stamps are this copy's own; Steer.inject! stamps the
209
+ # live merge a moment later.
205
210
  merged = merging.empty? ? [] : Steer.merge(merging).messages
206
211
  # One level deep: the loop may edit an entry in place later (a
207
212
  # forget stub) while a save on another thread serializes this one.
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.109.0"
4
+ VERSION = "0.110.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -38,6 +38,7 @@ require_relative "../version"
38
38
  require_relative "../config"
39
39
  require_relative "../output_formatter"
40
40
  require_relative "../image_store"
41
+ require_relative "../message_time"
41
42
  require_relative "../context_note"
42
43
  require_relative "../context_sources"
43
44
  require_relative "../context_providers"
@@ -2683,11 +2684,12 @@ module Samagotchi
2683
2684
  # a live worker's (it counts against its Engine's messages), else
2684
2685
  # recap.json against the session file. A stopped session gets its
2685
2686
  # recap without a worker being woken.
2686
- # @return [Hash, nil] {text:, turns_since:}
2687
+ # @return [Hash, nil] {text:, turns_since:, created_at:} (created_at:
2688
+ # when it was written, UTC ISO; absent from an older recap)
2687
2689
  def saved_recap_for(id, session, turn_snapshot)
2688
2690
  if turn_snapshot
2689
2691
  saved = turn_snapshot["saved_recap"]
2690
- return saved && { text: saved["text"], turns_since: saved["turns_since"].to_i }
2692
+ return saved && { text: saved["text"], turns_since: saved["turns_since"].to_i, created_at: saved["created_at"] }.compact
2691
2693
  end
2692
2694
 
2693
2695
  saved = RecapStore.read(@session_class.session_dir(id, state_dir: default_state_dir))
@@ -2695,7 +2697,7 @@ module Samagotchi
2695
2697
 
2696
2698
  messages = Array(session.messages)
2697
2699
  since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.turn_prompt?(m) }
2698
- { text: saved[:text], turns_since: since }
2700
+ { text: saved[:text], turns_since: since, created_at: saved[:created_at] }.compact
2699
2701
  rescue StandardError
2700
2702
  nil
2701
2703
  end
@@ -2732,7 +2734,7 @@ module Samagotchi
2732
2734
  role = (m[:role] || m["role"]).to_s
2733
2735
  content = (m[:content] || m["content"]).to_s
2734
2736
  if Samagotchi::ContextNote.note?(m)
2735
- note = { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m) }
2737
+ note = { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m), **time_of(m) }
2736
2738
  # A context wake turn's note starts that turn (Steer.turn_prompt?):
2737
2739
  # the page opens the turn with it and pairs it with its record.
2738
2740
  if Samagotchi::Steer.turn_prompt?(m)
@@ -2745,7 +2747,7 @@ module Samagotchi
2745
2747
  # A plugin's steer: a row of the step that answered it, which the
2746
2748
  # page finds by `step`.
2747
2749
  if Samagotchi::Steer.steer?(m)
2748
- filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index) }
2750
+ filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index), **time_of(m) }
2749
2751
  next
2750
2752
  end
2751
2753
  # A turn that ended with no answer (TurnNote.empty's marker): its
@@ -2768,7 +2770,7 @@ module Samagotchi
2768
2770
  norm_role = "user" if norm_role == "user"
2769
2771
  next unless %w[user assistant].include?(norm_role)
2770
2772
 
2771
- message = { role: norm_role, content: stripped }
2773
+ message = { role: norm_role, content: stripped, **time_of(m) }
2772
2774
  # Merged into the running turn (Steer::INPUT_KIND): part of it,
2773
2775
  # read by step +step+; a wake turn's report starts its turn
2774
2776
  # (Steer.turn_prompt?). Its sender (a delegate report, chi send):
@@ -2808,6 +2810,13 @@ module Samagotchi
2808
2810
  []
2809
2811
  end
2810
2812
 
2813
+ # The entry's wall time (MessageTime: the page's hover and gap
2814
+ # dividers) as {at:}, or nothing for an entry saved without one.
2815
+ def time_of(message)
2816
+ at = Samagotchi::MessageTime.of(message)
2817
+ at.is_a?(String) && !at.empty? ? { at: at } : {}
2818
+ end
2819
+
2811
2820
  # A no-answer turn's marker as display items: each empty step that
2812
2821
  # has something to show (its thinking) as a text-less assistant step
2813
2822
  # (only with +parts+, as other text-less steps), then
@@ -108,7 +108,7 @@ module Samagotchi
108
108
  def turn_started(event, viewer)
109
109
  origin = TurnOrigin.of(field(event, :origin))
110
110
  author = field(event, :author) || origin.author
111
- out = { "type" => "turn_started", **id_fields(event) }
111
+ out = { "type" => "turn_started", **id_fields(event), **time_field(event) }
112
112
  out.merge!(author_fields(author, field(event, :author_name), viewer))
113
113
  prompt = field(event, :prompt)
114
114
  if field(event, :continue)
@@ -164,8 +164,8 @@ module Samagotchi
164
164
  # analytics records' +automatic+, and the running one's): their
165
165
  # prompt is shown as an automatic turn, without its text
166
166
  # @return [Array<Hash>] {role: "user", content, turn_id, author,
167
- # author_name, mine, automatic, images} and {role: "assistant",
168
- # content, tools: [{tool, path}]}; no notes, steers, automatic
167
+ # author_name, mine, automatic, images, at} and {role: "assistant",
168
+ # content, tools: [{tool, path}], at}; no notes, steers, automatic
169
169
  # input or empty-answer markers. html is added by the caller, from
170
170
  # this (redacted) content.
171
171
  def messages(shown, viewer, automatic: [])
@@ -194,7 +194,7 @@ module Samagotchi
194
194
  merged = field(message, :merged)
195
195
  return nil unless turn_id || merged
196
196
 
197
- out = { "role" => "user" }
197
+ out = { "role" => "user", **time_field(message) }
198
198
  if turn_id && automatic.include?(turn_id.to_s)
199
199
  out.merge!("content" => "", "automatic" => true, "turn_id" => turn_id.to_s)
200
200
  return out
@@ -215,7 +215,7 @@ module Samagotchi
215
215
  content = field(message, :content).to_s
216
216
  return nil if content.strip.empty? && rows.empty?
217
217
 
218
- out = { "role" => "assistant", "content" => content }
218
+ out = { "role" => "assistant", "content" => content, **time_field(message) }
219
219
  out["tools"] = rows unless rows.empty?
220
220
  out
221
221
  end
@@ -287,6 +287,13 @@ module Samagotchi
287
287
  out
288
288
  end
289
289
 
290
+ # When it happened (MessageTime's stamp, a turn's start): the page's
291
+ # hover and gap dividers. A wall time only, nothing of what was done.
292
+ def time_field(item)
293
+ at = field(item, :at)
294
+ at.is_a?(String) && !at.empty? ? { "at" => at } : {}
295
+ end
296
+
290
297
  def id_fields(event)
291
298
  out = seq(event)
292
299
  turn_id = field(event, :turn_id)
@@ -46,6 +46,7 @@ import { createCardsPlace } from "./cards_place.js";
46
46
  import { createStageView } from "./stage_view.js";
47
47
  import { createWorkingSet, groupItems } from "./working_set_view.js";
48
48
  import { turnGroups } from "./timing.js";
49
+ import { createGapClock, lastActivity } from "./time_gaps.js";
49
50
  import { createMarkdownPlace } from "./markdown_place.js";
50
51
  import { openerFill, openTitle, restoredComposer } from "./chains.js";
51
52
  import { createTaskStopper } from "./task_stop.js";
@@ -371,8 +372,11 @@ const bubbles = createBubbles({
371
372
  });
372
373
  const {
373
374
  removeHintIfEmpty, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap, markRecapStale,
374
- showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
375
+ showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble, addTimeGap,
375
376
  } = bubbles;
377
+ // The open session's last activity, for the live gap dividers
378
+ // (time_gaps.js): renderHistory seeds it, the stream moves it.
379
+ const gapClock = createGapClock();
376
380
  // The plugin cards and a snapshot's notices in the history (cards_place.js).
377
381
  const cards = createCardsPlace({
378
382
  historyEl,
@@ -947,7 +951,7 @@ function renderSessionView(data, { kept = [] } = {}) {
947
951
  if (!loadWarned("plugins")) showGuardrailWarning(data.plugin_warning, "plugins");
948
952
  // The recap saved with the session (also a stopped one's), else a live
949
953
  // worker's from before saved_recap existed.
950
- if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since);
954
+ if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since, data.saved_recap.created_at || null);
951
955
  else showRecap(data.recap);
952
956
  // A live worker's last cards and between-turns notices, where they
953
957
  // arrived; the running turn's go into its step once it is drawn.
@@ -1004,12 +1008,15 @@ function seedWorkingSet(data) {
1004
1008
  }
1005
1009
 
1006
1010
  function renderHistory(items) {
1011
+ gapClock.seed(null);
1007
1012
  if (!items.length) {
1008
1013
  historyEl.replaceChildren(hintEl("No output yet. Send a message."));
1009
1014
  return;
1010
1015
  }
1011
1016
  if (typeof items[0] === "object" && items[0].role) {
1012
1017
  historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images), workingSet: !!workingSet }));
1018
+ // The live dividers count from the history's last activity.
1019
+ gapClock.seed(lastActivity(turnGroups(items, current.timing), items));
1013
1020
  linkTextRefs();
1014
1021
  } else {
1015
1022
  historyEl.replaceChildren(...items.map((c) => elementOf(outputBubbleTemplate(c))));
@@ -1416,7 +1423,8 @@ const streamHandlers = createStreamHandlers({
1416
1423
  setUsedMemories, onInitEvent, findInTurnRoots, applyFinalMarkdown,
1417
1424
  // bubbles.js
1418
1425
  applyPromptOps, showSteers, markRecapStale, appendChunk, makeUserBubble, addEndBubble, commandBubble,
1419
- addCommandBubble, addNoteBubble, showRecap, showGuardrailWarning, showHookNotice,
1426
+ addCommandBubble, addNoteBubble, showRecap, showGuardrailWarning, showHookNotice, addTimeGap,
1427
+ gapClock,
1420
1428
  // cards_place.js
1421
1429
  upsertCard, refreshCardBodies,
1422
1430
  syncModelPick: startPage.commandRan,
@@ -4,7 +4,7 @@
4
4
  // notices, plugins' steers, command and note bubbles. The markup is lit
5
5
  // (turn_html.js elementOf); createBubbles puts them in the history.
6
6
  import { html, nothing } from "./vendor/lit-html.js";
7
- import { normalize, recapLabel, recapPlace } from "./format.js";
7
+ import { fullTime, normalize, recapLabel, recapPlace } from "./format.js";
8
8
  import { BADGES, elementOf, noteBubbleTemplate, stateBadgeTemplate, steerRowTemplate, userBubbleTemplate } from "./turn_html.js";
9
9
  import { thumbsTemplate } from "./images.js";
10
10
  import { appendAboveLiveTiming, STOPPED_LINE_CLASS } from "./timing.js";
@@ -12,6 +12,7 @@ import { keepFollowing } from "./scroll.js";
12
12
  import { linkedLine } from "./notice_links.js";
13
13
  import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
14
14
  import { drawCommandBubble } from "./commands.js";
15
+ import { TIME_GAP_CLASS, timeGapEl } from "./time_gaps.js";
15
16
 
16
17
  export function removeQueuedBubble(bubble) {
17
18
  if (!bubble) return;
@@ -49,8 +50,11 @@ export function endBubbleTemplate(text, { stopped = false } = {}) {
49
50
  }
50
51
 
51
52
  // The idle recap, open; a stale one (+turnsSince+ > 0) says it is earlier.
52
- export function recapTemplate(text, turnsSince = 0) {
53
- return html`<details class=${turnsSince > 0 ? "bubble recap stale" : "bubble recap"} open><summary>${recapLabel(turnsSince)}</summary><div class="recap-body">${text}</div></details>`;
53
+ // +createdAt+: when it was written (its label's time, kept for the stale
54
+ // label as data-created-at).
55
+ export function recapTemplate(text, turnsSince = 0, createdAt = null, now = Date.now()) {
56
+ const created = typeof createdAt === "string" && createdAt ? createdAt : nothing;
57
+ return html`<details class=${turnsSince > 0 ? "bubble recap stale" : "bubble recap"} data-created-at=${created} open><summary>${recapLabel(turnsSince, false, createdAt, now)}</summary><div class="recap-body">${text}</div></details>`;
54
58
  }
55
59
 
56
60
  // A guardrail hook or rule file that failed to load (once per worker), or
@@ -228,14 +232,16 @@ export function createBubbles({
228
232
  // the end of the history. A turn starting leaves it in place, marked
229
233
  // earlier; only a new recap replaces it.
230
234
 
231
- function showRecap(text, turnsSince = 0) {
235
+ function showRecap(text, turnsSince = 0, createdAt = null) {
232
236
  if (!text) return;
233
237
  removeRecap();
234
- const details = elementOf(recapTemplate(text, turnsSince));
238
+ const details = elementOf(recapTemplate(text, turnsSince, createdAt));
235
239
  // A stale one sits where it was written, above the turns it doesn't cover.
236
240
  // Never in the stage: a recap is between turns.
237
241
  turnDom.settleFold?.();
238
- const before = recapPlace(turnPromptBubbles(historyEl), turnsSince);
242
+ const prompt = recapPlace(turnPromptBubbles(historyEl), turnsSince);
243
+ // Above that turn's gap divider too: the recap was written before the quiet stretch ended.
244
+ const before = prompt?.previousElementSibling?.matches(`.${TIME_GAP_CLASS}`) ? prompt.previousElementSibling : prompt;
239
245
  keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
240
246
  }
241
247
 
@@ -244,7 +250,7 @@ export function createBubbles({
244
250
  historyEl.querySelectorAll(".bubble.recap:not(.stale)").forEach((el) => {
245
251
  el.classList.add("stale");
246
252
  const summary = el.querySelector("summary");
247
- if (summary) summary.textContent = recapLabel(0, true);
253
+ if (summary) summary.textContent = recapLabel(0, true, el.dataset.createdAt || null);
248
254
  });
249
255
  }
250
256
 
@@ -282,6 +288,9 @@ export function createBubbles({
282
288
  const id = selected();
283
289
  if (id) div.dataset.keptFor = id;
284
290
  drawCommandBubble(div, view);
291
+ // When it happened (a reminder's at): the hover, as a reload's bubbles.
292
+ const when = fullTime(view.at);
293
+ if (when) div.title = when;
285
294
  placeFollowing(div);
286
295
  }
287
296
 
@@ -295,8 +304,21 @@ export function createBubbles({
295
304
  placeFollowing(elementOf(noteBubbleTemplate(note)));
296
305
  }
297
306
 
307
+ // A quiet stretch passed (time_gaps.js): its divider above +before+ (a
308
+ // turn's prompt bubble, wherever it sits), else at the end.
309
+ function addTimeGap(text, { before = null } = {}) {
310
+ if (!text) return null;
311
+ const el = timeGapEl(text);
312
+ if (before?.parentNode) {
313
+ keepFollowing(historyEl, () => before.parentNode.insertBefore(el, before));
314
+ return el;
315
+ }
316
+ return placeFollowing(el);
317
+ }
318
+
298
319
  return {
299
320
  removeHintIfEmpty, placeFollowing, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
300
321
  markRecapStale, showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
322
+ addTimeGap,
301
323
  };
302
324
  }