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
@@ -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
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Samagotchi
6
+ # When a history entry happened: its wall-clock stamp (KEY), and the
7
+ # dividers the UIs print when a quiet gap passes between two entries.
8
+ #
9
+ # The stamp is display-only: no wire format reads it, so the bytes the
10
+ # model gets (and its prompt cache) are the same with or without it. A
11
+ # time the model should read is baked into the message's content when the
12
+ # message is made (ReminderQueue's header), never rendered at wire time.
13
+ #
14
+ # Everything here is a pure function of the times passed in; the one wall
15
+ # clock is #now (a seam the specs pin to a fixed time).
16
+ module MessageTime
17
+ # The history entry key: a local-offset ISO 8601 time with milliseconds.
18
+ KEY = :at
19
+ # The quiet time after which the UIs mark a gap.
20
+ GAP_SECONDS = 600
21
+
22
+ DEFAULT_SOURCE = -> { Time.now }
23
+ private_constant :DEFAULT_SOURCE
24
+
25
+ class << self
26
+ # The wall clock: a callable returning a Time (nil: Time.now).
27
+ attr_writer :source
28
+
29
+ # @return [Time] now, by the wall clock seam
30
+ def now = (@source || DEFAULT_SOURCE).call
31
+
32
+ # Back to Time.now.
33
+ def reset! = @source = nil
34
+ end
35
+
36
+ module_function
37
+
38
+ # @param time [Time]
39
+ # @return [String] the stamp's text
40
+ def iso(time) = time.iso8601(3)
41
+
42
+ # The stamp of an ISO text from elsewhere (a note's created_at), or nil
43
+ # when it doesn't parse.
44
+ def iso_of(text)
45
+ text && iso(Time.iso8601(text.to_s))
46
+ rescue ArgumentError
47
+ nil
48
+ end
49
+
50
+ # +message+ with KEY set to +time+'s stamp, unless it has one already
51
+ # (a Symbol- or String-keyed message). Changes +message+ and returns it.
52
+ def stamp(message, time)
53
+ return message if message.key?(KEY) || message.key?(KEY.to_s)
54
+
55
+ message[KEY] = time.is_a?(String) ? time : iso(time)
56
+ message
57
+ end
58
+
59
+ # @return [String, nil] the stamp of +message+ (Symbol or String keys)
60
+ def of(message)
61
+ return nil unless message.is_a?(Hash)
62
+
63
+ message[KEY] || message[KEY.to_s]
64
+ end
65
+
66
+ # @return [String, nil] "14:32", local time
67
+ def clock(iso) = local(iso)&.strftime("%H:%M")
68
+
69
+ # @return [String, nil] "Sat 24 Oct 09:10", local time
70
+ def day_clock(iso) = local(iso)&.strftime("%a %-d %b %H:%M")
71
+
72
+ # The divider between activity that ended at +prev_iso+ and activity
73
+ # starting at +cur_iso+: nil below GAP_SECONDS (or when either time is
74
+ # unknown), else "── 14:32 · 47 min later ──", the date added when the
75
+ # local day changed.
76
+ def gap_line(prev_iso, cur_iso)
77
+ prev = local(prev_iso)
78
+ cur = local(cur_iso)
79
+ return nil unless prev && cur
80
+
81
+ seconds = cur - prev
82
+ return nil if seconds < GAP_SECONDS
83
+
84
+ time = prev.to_date == cur.to_date ? cur.strftime("%H:%M") : cur.strftime("%a %-d %b %H:%M")
85
+ "── #{time} · #{ago(seconds)} later ──"
86
+ end
87
+
88
+ # @return [String] "47 min", "3 h", "2 d" (whole units, rounded down;
89
+ # the web's relativeTime scale)
90
+ def ago(seconds)
91
+ seconds = seconds.to_f
92
+ return "#{[(seconds / 60).floor, 0].max} min" if seconds < 3600
93
+ return "#{(seconds / 3600).floor} h" if seconds < 86_400
94
+
95
+ "#{(seconds / 86_400).floor} d"
96
+ end
97
+
98
+ def local(iso)
99
+ iso && Time.iso8601(iso.to_s).localtime
100
+ rescue ArgumentError
101
+ nil
102
+ end
103
+ end
104
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "monitor"
4
+ require_relative "message_time"
4
5
 
5
6
  module Samagotchi
6
7
  # The session's due reminders, on their way into a turn.
@@ -11,9 +12,16 @@ module Samagotchi
11
12
  # turn while they are pending. A turn injects whatever is due then
12
13
  # (#inject!), which marks it fired and clears the pending names.
13
14
  class ReminderQueue
15
+ # The reminders message's first line at +time+ (a Time in the zone to
16
+ # show: Time.now's is the local one).
17
+ def self.header(time) = "[SYSTEM: REMINDERS DUE #{time.strftime("%Y-%m-%d %H:%M")} local]"
18
+
14
19
  # @param store [ReminderStore, nil]
15
- def initialize(store:)
20
+ # @param now [#call] the wall clock (a Time) for the injected message's
21
+ # stamp, when #inject! is given none
22
+ def initialize(store:, now: -> { MessageTime.now })
16
23
  @store = store
24
+ @now = now
17
25
  @lock = Monitor.new
18
26
  @pending = []
19
27
  end
@@ -55,14 +63,23 @@ module Samagotchi
55
63
  # A tail message, not the head system prompt: changing the head would
56
64
  # make the model server re-evaluate the whole conversation's prefix
57
65
  # (its KV cache) every interval; a tail costs only the new suffix.
66
+ # The header names the local time the reminders came due at, for the
67
+ # model ("[SYSTEM: REMINDERS DUE 2026-10-24 14:32 local]"): baked into
68
+ # the content here, never rendered at wire time, so a replay sends the
69
+ # same bytes (the prompt cache) whatever the time zone or DST. The
70
+ # message also carries that time as its stamp (MessageTime::KEY,
71
+ # display-only).
58
72
  # @param messages [Array<Hash>] the turn's messages (appended to)
73
+ # @param now [Time, nil] the turn's wall time (nil: the clock's)
59
74
  # @return [Array<Hash>] the reminders injected (maybe none)
60
- def inject!(messages)
75
+ def inject!(messages, now: nil)
61
76
  reminders = due
62
77
  return [] if reminders.empty?
63
78
 
79
+ time = now || @now.call
64
80
  lines = reminders.map { |r| " #{r[:name]}: #{r[:description]} (interval: #{r[:interval_minutes]}m)" }.join("\n")
65
- messages << { role: "system", content: "[SYSTEM: REMINDERS DUE]\n#{lines}\n[END REMINDERS]" }
81
+ messages << { role: "system", content: "#{self.class.header(time)}\n#{lines}\n[END REMINDERS]",
82
+ MessageTime::KEY => MessageTime.iso(time) }
66
83
  @store.mark_fired_batch(reminders.map { |r| r[:name] })
67
84
  # Without this a turn's injection leaves a stale name, and the next
68
85
  # reminder turn would run with nothing due.
@@ -27,7 +27,7 @@ module Samagotchi
27
27
  @ended_before = ended_before
28
28
  @deadline = timeout && (ReplyWait.monotonic + timeout.to_f)
29
29
  @turn = turn.to_s.empty? ? nil : turn.to_s
30
- @session_dir = Session.session_dir(id, state_dir: state_dir) if @turn
30
+ @session_dir = Session.session_dir(id, state_dir: state_dir)
31
31
  # Whether the turn's input file has been taken (latched): looked at
32
32
  # before each load, so a running session seen after it is that turn
33
33
  # or a later one, never the turn that ran when the message was sent.
@@ -40,6 +40,11 @@ module Samagotchi
40
40
  @seen_own = false
41
41
  @own_base = nil
42
42
  @seen_running = false
43
+ # A turn seen running with its checkpoint mark (a wait with no
44
+ # turn:): last_turn's ended_at then and the input files claimed
45
+ # then, so its settle by a next worker (#settled_running) tells
46
+ # from a turn that ended or an input that runs next.
47
+ @running_look = nil
43
48
  @gone_since = nil
44
49
  end
45
50
 
@@ -216,8 +221,13 @@ module Samagotchi
216
221
 
217
222
  if running?(session)
218
223
  @seen_running = true
224
+ note_running(session)
219
225
  return nil
220
226
  end
227
+ if (settled = settled_running(session))
228
+ return settled
229
+ end
230
+
221
231
  # It ran and is idle again with no new reply: canceled, failed or
222
232
  # empty. Before it was ever seen running, idle means a
223
233
  # file-delivered message its worker has not picked up yet. With a
@@ -254,6 +264,34 @@ module Samagotchi
254
264
  nil
255
265
  end
256
266
 
267
+ RunningLook = Data.define(:last_end, :claimed)
268
+
269
+ def note_running(session)
270
+ return unless session.interrupted_turn
271
+
272
+ @running_look = RunningLook.new(last_end: session.last_turn&.ended_at,
273
+ claimed: SessionInbox.claimed_input_files(@session_dir))
274
+ end
275
+
276
+ # The turn seen running is gone with no turn's end: its worker died
277
+ # in it and the next one settled it (TurnCheckpoint.settle: idle, the
278
+ # mark gone, last_turn left alone), which ends a wait for it (chi
279
+ # send --wait with no message, a message merged into it as a steer)
280
+ # as canceled, worker_stopped, rather than at the timeout. Not while
281
+ # an input waits or a new one was claimed since (the sent message,
282
+ # unmerged, runs as its own turn next: its end is the answer), nor
283
+ # for a settle never seen running (a message sent to a session a dead
284
+ # worker left running goes in after the settle).
285
+ def settled_running(session)
286
+ look = @running_look
287
+ return nil unless look && session.status == Session::STATUS_IDLE && session.interrupted_turn.nil? &&
288
+ session.last_turn&.ended_at == look.last_end
289
+ return nil unless SessionInbox.find_new_input_files(@session_dir).empty?
290
+ return nil unless (SessionInbox.claimed_input_files(@session_dir) - look.claimed).empty?
291
+
292
+ Result.new(status: :no_reply, outcome: "canceled", cancel_reason: TurnCheckpoint::WORKER_STOPPED, session: session)
293
+ end
294
+
257
295
  def owner_gone(session)
258
296
  return nil unless @owner_grace
259
297
 
@@ -14,7 +14,7 @@ module Samagotchi
14
14
  module RunOptions
15
15
  # The flags an underscored spelling is refused for (--recap_base_url),
16
16
  # with the config registry's.
17
- KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token].freeze
17
+ KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token --tunnel].freeze
18
18
 
19
19
  module_function
20
20
 
@@ -219,6 +219,9 @@ module Samagotchi
219
219
  opts.on("--port PORT", "Port for `chi web` (default: 4567, env SAMAGOTCHI_WEB_PORT)") { |p| options[:web_port] = p.to_i; cli_overrides["web.port"] = p.to_s }
220
220
  opts.on("--open", "Open browser for `chi web`") { options[:web_open] = true }
221
221
  opts.on("--new-token", "`chi web`: replace the LAN access token (web.host: lan); links and QR codes made with the old one stop working") { options[:new_token] = true }
222
+ opts.on("--tunnel PROVIDER", %w[tailscale],
223
+ "`chi web`: reach it over https from your tailnet through Tailscale Serve (never Funnel), for this run; " \
224
+ "turned off on exit") { |v| options[:tunnel] = v }
222
225
  opts.on("--scope VALUE", %w[project all],
223
226
  "Which sessions `chi web` and `chi sessions list` show: project (the default: this folder's git project; " \
224
227
  "every session outside a repo) or all") { |v| options[:scope] = v }
@@ -150,7 +150,7 @@ module Samagotchi
150
150
  --llm-context-budget --toolkit --tool --no-tool --set --parent-answers --no-interrupt
151
151
  --no-default-input --dir --version -h --help],
152
152
  LOCAL_ONLY => %w[--no-shared --non-interactive -v --verbose --append-system-prompt --memory --mute --profile
153
- --port --open --new-token --scope --web-markdown --no-web-markdown]
153
+ --port --open --new-token --tunnel --scope --web-markdown --no-web-markdown]
154
154
  }.freeze
155
155
  # A short run flag's name in a refusal.
156
156
  RUN_WORDS = { "-v" => "--verbose" }.freeze
@@ -364,8 +364,9 @@ module Samagotchi
364
364
  end
365
365
 
366
366
  # The link names this machine's chi web: web.public_url from this
367
- # machine's config (never what the port answers) when it is set (the
368
- # token in the fragment, as chi web's own invite), else its LAN address
367
+ # machine's config (never what the port answers) when it is set, or
368
+ # the one a running chi web --tunnel recorded (the code in the
369
+ # fragment, as chi web's own invite), else its LAN address
369
370
  # and port when one runs on the LAN (the port it recorded, else
370
371
  # web.port), else 127.0.0.1 (+lan+ false: the link opens on this
371
372
  # machine only).
@@ -431,10 +432,15 @@ module Samagotchi
431
432
  Web::LinkCodes.for_grants(grants.path).issue(kind: :grant, token: issued.token, ttl: ttl)
432
433
  end
433
434
 
434
- # web.public_url as this machine's config has it; nil when unset or
435
- # not a valid one (chi web refuses to start on that).
435
+ # web.public_url as this machine's config has it (nil when not a
436
+ # valid one: chi web refuses to start on that), else the one the
437
+ # running chi web recorded (chi web --tunnel's), only while that same
438
+ # process answers on its port (Web::Running.live_public_url).
436
439
  def configured_public_url
437
- Web::PublicUrl.from_config(url: Config.get("web.public_url"))
440
+ configured = Config.get("web.public_url")
441
+ return Web::Running.live_public_url(path: Web::Running.path(state_dir: state_dir!)) if configured.to_s.strip.empty?
442
+
443
+ Web::PublicUrl.from_config(url: configured)
438
444
  rescue Web::PublicUrl::Invalid
439
445
  nil
440
446
  end
@@ -371,10 +371,14 @@ module Samagotchi
371
371
  claimed_input?(session_dir, id) ? [:claimed, nil] : [:none, nil]
372
372
  end
373
373
 
374
- # Whether a claimed input file (*.json.processing: a turn's, now or
375
- # left by a crash, or a removal's before it deletes it) carries +id+.
374
+ # The claimed input files (*.json.processing: a turn's, now or left by
375
+ # a crash, or a removal's before it deletes it).
376
+ # @return [Array<String>] their paths
377
+ def self.claimed_input_files(session_dir) = Dir.glob(File.join(session_dir, INPUT_DIR, "*.json.processing"))
378
+
379
+ # Whether a claimed input file carries +id+.
376
380
  def self.claimed_input?(session_dir, id)
377
- Dir.glob(File.join(session_dir, INPUT_DIR, "*.json.processing")).any? do |file|
381
+ claimed_input_files(session_dir).any? do |file|
378
382
  data = JSON.parse(File.read(file))
379
383
  data.is_a?(Hash) && data["enqueued_id"] == id
380
384
  rescue JSON::ParserError, SystemCallError
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "client_id"
4
4
  require_relative "participant_name"
5
+ require_relative "message_time"
5
6
 
6
7
  module Samagotchi
7
8
  # A plugin's steer: text a plugin puts into the running turn (Engine#steer),
@@ -53,15 +54,16 @@ module Samagotchi
53
54
 
54
55
  # The messages it appends: the merged input first (one message per
55
56
  # sender run, marked kind input, source: when not the user's), then
56
- # each steer.
57
- def messages
57
+ # each steer; all stamped with +at+ (MessageTime::KEY), the drain's
58
+ # wall time.
59
+ def messages(at: MessageTime.iso(MessageTime.now))
58
60
  list = inputs.map do |input|
59
61
  message = { role: "user", kind: INPUT_KIND }
60
62
  message[:source] = input[:source] if input[:source]
61
63
  message.merge!(input[:mark]) if input[:mark]
62
64
  message.merge(content: input[:content], **Steer.author_fields(input[:author], input[:author_name]))
63
65
  end
64
- list + steers.map { |steer| Steer.message(**steer) }
66
+ (list + steers.map { |steer| Steer.message(**steer) }).map { |message| MessageTime.stamp(message, at) }
65
67
  end
66
68
 
67
69
  # The :pending_input_merged fields beside iteration and answer;
@@ -14,6 +14,7 @@ require_relative "line_reader"
14
14
  require_relative "question_prompt"
15
15
  require_relative "reline_seam"
16
16
  require_relative "version_lines"
17
+ require_relative "gap_clock"
17
18
  require_relative "participant_commands"
18
19
  require_relative "local_link"
19
20
  require_relative "../bridge/turn_accumulator"
@@ -100,6 +101,7 @@ module Samagotchi
100
101
  # @param default_input [Boolean] type SAMAGOTCHI_DEFAULT_INPUT into
101
102
  # the first read (a new session with no -p, as the REPL)
102
103
  # @param clock [#call] monotonic seconds (the Ctrl-C detach window)
104
+ # @param now [#call] the wall clock, a Time (the gap dividers)
103
105
  # @param delete_session [#call] session id -> deletes it (/exit --delete)
104
106
  # @param archive_session [#call] session id -> archives it (/archive);
105
107
  # SessionManager.archive_session's result
@@ -114,7 +116,7 @@ module Samagotchi
114
116
  # +client+ and the keywords that make a LocalLink
115
117
  def initialize(screen:, client_id:, client: nil, first_prompt: nil, first_command: nil, no_interrupt: false,
116
118
  default_input: false, wait_at_eof: false, as_parent: false,
117
- clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
119
+ clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, now: -> { MessageTime.now },
118
120
  delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: LocalLink::DELETE_WAIT) },
119
121
  archive_session: ->(id) { SessionManager.archive_session(id, wait: LocalLink::DELETE_WAIT) },
120
122
  installed_version: -> { InstalledVersions.new.newest },
@@ -152,6 +154,8 @@ module Samagotchi
152
154
  @no_interrupt = no_interrupt
153
155
  @no_default_input = !default_input
154
156
  @clock = clock
157
+ # The session's last activity, for the gap dividers (GapClock).
158
+ @gaps = GapClock.new(now: now)
155
159
  @last_idle_interrupt_at = nil
156
160
  @next_input_prefill = nil
157
161
  # Prompts this run sent, by enqueued_id: only those come back into
@@ -208,7 +212,12 @@ module Samagotchi
208
212
  end
209
213
  read = input || method(:read_input_line)
210
214
  # A command typed after /unqueue's text echoes as the command alone.
211
- echoed_read = ->(prompt, prefill) { RelineSeam.echo_as(method(:prefilled_echo)) { read.call(prompt, prefill) } }
215
+ # A typed prompt after a quiet gap has its divider above its echo.
216
+ echoed_read = lambda do |prompt, prefill|
217
+ RelineSeam.echo_as(method(:prefilled_echo)) do
218
+ RelineSeam.echo_above(method(:gap_above_echo)) { read.call(prompt, prefill) }
219
+ end
220
+ end
212
221
  @reader = LineReader.new(queue, prompt: method(:prompt_text), read: echoed_read, prefill: default_input_text).start
213
222
  loop do
214
223
  kind, payload = next_item(queue)
@@ -280,10 +289,12 @@ module Samagotchi
280
289
  when :turn_dequeued then turn_dequeued(event)
281
290
  when :turn_started
282
291
  @status.take_event(event)
292
+ mark_activity
283
293
  start_turn(event)
284
294
  when :turn_completed
285
295
  @status.take_event(event)
286
296
  complete_turn(event)
297
+ @gaps.touch
287
298
  refresh_children
288
299
  own_turn_ended(event)
289
300
  # The renderer says how it ended (the REPL's words too).
@@ -291,6 +302,7 @@ module Samagotchi
291
302
  @status.take_event(event)
292
303
  @renderer.call(event)
293
304
  end_turn
305
+ @gaps.touch
294
306
  refresh_children
295
307
  own_turn_ended(event)
296
308
  when :prompt_restored then restore_prompt(event)
@@ -307,10 +319,14 @@ module Samagotchi
307
319
  when :command_ran
308
320
  command_ran(event)
309
321
  return first_command_ran(event) if @first_command_id && event[:command_id] == @first_command_id
310
- when :reminder_injected then @screen.commit(reminder_line(event[:reminders]))
322
+ when :reminder_injected
323
+ mark_activity
324
+ @screen.commit(reminder_line(event[:reminders]))
311
325
  # A context note joined the conversation (between turns). One dim
312
326
  # line in the scrollback; the notes slot is for question choices.
313
- when :context_added then @screen.commit(context_note_line(event[:label], event[:text]))
327
+ when :context_added
328
+ mark_activity
329
+ @screen.commit(context_note_line(event[:label], event[:text]))
314
330
  when :continue_offered then offer_continue(event)
315
331
  when :continue_resolved then continue_resolved(event)
316
332
  # The note comes with the kernel's :pending_input_merged (EventRenderer),
@@ -331,7 +347,7 @@ module Samagotchi
331
347
  when :question_relay then question_relayed(event)
332
348
  # Written while the session sat idle: shown at the open prompt. One
333
349
  # collected just as a turn started describes the chat before it.
334
- when :recap_ready then @screen.commit(recap_block(event[:recap])) unless @running || event[:recap].to_s.strip.empty?
350
+ when :recap_ready then show_recap_ready(event) unless @running || event[:recap].to_s.strip.empty?
335
351
  when :guardrail_warning then @screen.commit(EventRenderer.load_warning_line(event))
336
352
  # A plugin's slow setup: the activity row turns while it runs; a
337
353
  # line when it is done (a failure is a warn card).
@@ -359,6 +375,32 @@ module Samagotchi
359
375
 
360
376
  private
361
377
 
378
+ # A recap written while idle, with the time it was written.
379
+ def show_recap_ready(event)
380
+ mark_activity
381
+ @screen.commit(recap_block(event[:recap], at: event[:created_at]))
382
+ end
383
+
384
+ # Activity starts now: a gap's divider first when one passed since the
385
+ # last (none while a turn runs: its steps are no gap).
386
+ def mark_activity
387
+ line = @gaps.divider(quiet: @running)
388
+ @screen.commit(gap_divider(line)) if line
389
+ end
390
+
391
+ # The divider above a typed line's echo (RelineSeam.echo_above, on the
392
+ # reader thread): a prompt typed after a quiet gap, at an idle prompt.
393
+ # It marks the activity, so the turn's start marks no second one.
394
+ def gap_above_echo(text)
395
+ return nil if @running
396
+
397
+ route = ComposerRoute.call(text.to_s.strip, running: false, registry: command_registry)
398
+ return nil unless %i[prompt deliver].include?(route.kind)
399
+
400
+ line = @gaps.divider
401
+ line && gap_divider(line)
402
+ end
403
+
362
404
  # The newest chi installed, looked at once, at the join.
363
405
  def installed_version
364
406
  @link.installed_version
@@ -936,6 +978,9 @@ module Samagotchi
936
978
  return own_prompt_failed if images.nil?
937
979
 
938
980
  running = @running
981
+ # Off a live region (no echo seam) the divider comes here; after
982
+ # #gap_above_echo it marks none.
983
+ mark_activity
939
984
  options = { prompt: text, client_id: client_id_for(text) }
940
985
  options[:no_interrupt] = true if @no_interrupt
941
986
  options[:images] = images unless images.empty?
@@ -1315,8 +1360,9 @@ module Samagotchi
1315
1360
  @attached = true
1316
1361
  # The recap first: what the session was about, then where it stopped.
1317
1362
  saved = snapshot[:saved_recap]
1318
- @screen.commit(recap_block(saved[:text], turns_since: saved[:turns_since])) if saved && !saved[:text].to_s.strip.empty?
1363
+ @screen.commit(recap_block(saved[:text], turns_since: saved[:turns_since], at: saved[:created_at])) if saved && !saved[:text].to_s.strip.empty?
1319
1364
  render_join_header(Array(snapshot[:messages]))
1365
+ render_join_gap(snapshot, saved)
1320
1366
  @screen.commit("guardrails> #{snapshot[:guardrail_warning]}") if snapshot[:guardrail_warning]
1321
1367
  @screen.commit("plugins> #{snapshot[:plugin_warning]}") if snapshot[:plugin_warning]
1322
1368
  version_line = VersionLines.at_attach(worker: snapshot[:chi_version], installed: installed_version,
@@ -1431,6 +1477,17 @@ module Samagotchi
1431
1477
  end
1432
1478
  end
1433
1479
 
1480
+ # Where the session's last activity was: its newest stamped entry,
1481
+ # else its saved recap's time. A gap ago (and no turn running): one
1482
+ # line after the join block says when.
1483
+ def render_join_gap(snapshot, saved)
1484
+ @gaps.seed(snapshot[:messages], fallback: saved&.dig(:created_at))
1485
+ return if snapshot[:current_turn]
1486
+
1487
+ line = @gaps.join_line
1488
+ @screen.commit(gap_divider(line)) if line
1489
+ end
1490
+
1434
1491
  def render_join_header(messages)
1435
1492
  exchange = messages.select { |m| %w[user model assistant].include?(m[:role].to_s) }
1436
1493
  # The id stays findable: the detach line and chi sessions list show it.
@@ -1537,17 +1594,19 @@ module Samagotchi
1537
1594
  # A saved call's row; "no result" for a call the turn never answered
1538
1595
  # (it ended first). Its status is the saved record's, as live and the
1539
1596
  # web's reload (a guardrail's "blocked" is no error), else read from
1540
- # its result.
1597
+ # its result (a guardrail's deny text reads blocked too).
1541
1598
  def join_tool_line(part, duration_ms: nil, saved_status: nil)
1542
1599
  output = part[:output]
1543
1600
  command = part.dig(:view, :truncated) ? nil : part.dig(:view, :command)
1601
+ text = ToolActivity.saved_result_text(output) unless output.nil?
1544
1602
  status = if output.nil?
1545
1603
  "no result"
1546
1604
  elsif !saved_status.to_s.empty?
1547
1605
  saved_status.to_s
1606
+ elsif ToolActivity.guardrail_denied?(text)
1607
+ "blocked"
1548
1608
  else
1549
- ToolActivity.tool_activity_status(ToolActivity.saved_result_text(output), part[:tool],
1550
- command: command)
1609
+ ToolActivity.tool_activity_status(text, part[:tool], command: command)
1551
1610
  end
1552
1611
  activity = { action: part[:label] || ToolActivity.tool_activity_action(part[:tool]), tool: part[:tool],
1553
1612
  params: part[:params], status: status,
@@ -1658,6 +1717,8 @@ module Samagotchi
1658
1717
  def show_enqueued(event)
1659
1718
  return if own?(event[:client_id])
1660
1719
 
1720
+ # Another client's prompt shows here, before its turn starts.
1721
+ mark_activity
1661
1722
  @shown_enqueued << event[:enqueued_id]
1662
1723
  line = prompt_line(event[:client_id], event[:prompt], **author_of(event[:author]))
1663
1724
  @screen.commit(event[:delivery] == Delivery::QUEUE ? "#{line} #{QUEUED_NOTE}" : line)