samagotchi 0.108.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -1
  3. data/bin/chi +5 -1
  4. data/docs/architecture.md +7 -2
  5. data/docs/cli.md +40 -1
  6. data/docs/configuration.md +177 -2
  7. data/docs/internals/engine.md +19 -4
  8. data/docs/internals/participants.md +28 -8
  9. data/docs/internals/prompt-caching.md +5 -0
  10. data/docs/internals/remote-sessions.md +6 -2
  11. data/docs/plugins.md +1 -1
  12. data/docs/sessions.md +3 -1
  13. data/docs/sub-agent.md +1 -1
  14. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  15. data/lib/samagotchi/bridge.rb +25 -0
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/config.rb +4 -0
  18. data/lib/samagotchi/context_note.rb +7 -3
  19. data/lib/samagotchi/engine.rb +20 -9
  20. data/lib/samagotchi/idle_recap.rb +1 -1
  21. data/lib/samagotchi/idle_reminders.rb +1 -1
  22. data/lib/samagotchi/kernel_loop.rb +12 -7
  23. data/lib/samagotchi/llm/chat_loop.rb +9 -2
  24. data/lib/samagotchi/message_time.rb +104 -0
  25. data/lib/samagotchi/proxy_trace.rb +45 -0
  26. data/lib/samagotchi/reminder_queue.rb +20 -3
  27. data/lib/samagotchi/reply_wait/poll.rb +39 -1
  28. data/lib/samagotchi/run_options.rb +4 -1
  29. data/lib/samagotchi/session_access/contract.rb +1 -1
  30. data/lib/samagotchi/session_access/local.rb +52 -10
  31. data/lib/samagotchi/session_inbox.rb +7 -3
  32. data/lib/samagotchi/steer.rb +5 -3
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
  34. data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
  35. data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
  36. data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
  37. data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
  38. data/lib/samagotchi/terminal_ui.rb +40 -5
  39. data/lib/samagotchi/tool_activity.rb +11 -0
  40. data/lib/samagotchi/turn_checkpoint.rb +6 -1
  41. data/lib/samagotchi/version.rb +1 -1
  42. data/lib/samagotchi/web/app.rb +246 -32
  43. data/lib/samagotchi/web/grants.rb +6 -3
  44. data/lib/samagotchi/web/info_probe.rb +5 -1
  45. data/lib/samagotchi/web/link_codes.rb +146 -0
  46. data/lib/samagotchi/web/participant_access.rb +9 -2
  47. data/lib/samagotchi/web/participant_review.rb +1 -1
  48. data/lib/samagotchi/web/participant_view.rb +12 -5
  49. data/lib/samagotchi/web/participants_admin.rb +13 -6
  50. data/lib/samagotchi/web/proxy_gate.rb +174 -0
  51. data/lib/samagotchi/web/public/app.js +35 -3
  52. data/lib/samagotchi/web/public/auth_lost.js +76 -0
  53. data/lib/samagotchi/web/public/bubbles.js +29 -7
  54. data/lib/samagotchi/web/public/data.js +29 -5
  55. data/lib/samagotchi/web/public/format.js +72 -6
  56. data/lib/samagotchi/web/public/index.html +2 -0
  57. data/lib/samagotchi/web/public/participant.html +2 -0
  58. data/lib/samagotchi/web/public/participant.js +47 -10
  59. data/lib/samagotchi/web/public/participant_model.js +25 -2
  60. data/lib/samagotchi/web/public/stream_handlers.js +32 -4
  61. data/lib/samagotchi/web/public/time_gaps.js +98 -0
  62. data/lib/samagotchi/web/public/token_fragment.js +57 -0
  63. data/lib/samagotchi/web/public/tokens.css +2 -2
  64. data/lib/samagotchi/web/public/turn_events.js +3 -1
  65. data/lib/samagotchi/web/public/turn_html.js +15 -8
  66. data/lib/samagotchi/web/public/turn_view.js +8 -3
  67. data/lib/samagotchi/web/public_url.rb +87 -0
  68. data/lib/samagotchi/web/running.rb +39 -4
  69. data/lib/samagotchi/web/server.rb +138 -22
  70. data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
  71. data/lib/samagotchi/web/tunnel.rb +133 -0
  72. data/lib/samagotchi/worker_sidecar.rb +4 -3
  73. metadata +12 -1
@@ -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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "socket"
4
+
3
5
  require_relative "../session"
4
6
  require_relative "../session_access"
5
7
  require_relative "../reply_wait"
@@ -18,6 +20,8 @@ require_relative "../participants"
18
20
  require_relative "../config"
19
21
  require_relative "../web/grants"
20
22
  require_relative "../web/info_probe"
23
+ require_relative "../web/public_url"
24
+ require_relative "../web/link_codes"
21
25
  require_relative "../web/running"
22
26
 
23
27
  module Samagotchi
@@ -359,17 +363,25 @@ module Samagotchi
359
363
  grants.list(session_id: id).map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
360
364
  end
361
365
 
362
- # The link names this machine's chi web: its LAN address and port
363
- # when one runs on the LAN (the port it recorded, else web.port),
364
- # else 127.0.0.1 (+lan+ false).
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, 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
370
+ # and port when one runs on the LAN (the port it recorded, else
371
+ # web.port), else 127.0.0.1 (+lan+ false: the link opens on this
372
+ # machine only).
365
373
  # @return [Invitation]
366
374
  # @raise [ArgumentError] no such session
367
375
  # @raise [BadRequest] a bad name, kind or ttl, an archived session
368
376
  def invite(id, name:, kind:, ttl: nil, cap: nil)
369
377
  issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, cap: cap, state_dir: state_dir!)
370
- ip, port = local_web
378
+ public_url = configured_public_url
379
+ web = public_url ? nil : local_web
380
+ link = if public_url then public_url.link(link_code(issued), "s=#{id}")
381
+ else "http://#{web.ip || "127.0.0.1"}:#{web.port}/?token=#{issued.token}#s=#{id}"
382
+ end
371
383
  Invitation.new(participant: ParticipantView.from_h(issued.grant.public_h(grants.now)), token: issued.token,
372
- link: "http://#{ip || "127.0.0.1"}:#{port}/?token=#{issued.token}#s=#{id}", lan: !ip.nil?,
384
+ link: link, lan: !public_url.nil? || !web.ip.nil?,
373
385
  warning: ReviewStart.share_warning(Session.load(id, **dir)))
374
386
  rescue Web::Grants::Invalid => e
375
387
  raise BadRequest.new(e.message, code: "invalid_participant")
@@ -400,18 +412,48 @@ module Samagotchi
400
412
  WEB_PORT = 4567
401
413
  LOCAL_PROBE_TIMEOUT = 1.0
402
414
 
403
- # [LAN ip or nil, port] of this machine's chi web (GET /api/info):
404
- # the port the running one recorded (Web::Running) when a chi web
405
- # answers there, else config's web.port.
415
+ # Where this machine's chi web answers (GET /api/info): its LAN ip
416
+ # (nil without one) and the port the running one recorded
417
+ # (Web::Running) when a chi web answers there, else config's web.port.
418
+ LocalWeb = Data.define(:ip, :port)
419
+
420
+ # The LAN address a probe answered, only if it is one of this
421
+ # machine's: whatever listens on the port may be another user's
422
+ # listener naming its own (nil then: the link names 127.0.0.1).
423
+ def own_address(ip)
424
+ return nil unless ip.is_a?(String)
425
+
426
+ Socket.ip_address_list.any? { |addr| addr.ip_address == ip } ? ip : nil
427
+ end
428
+
429
+ # A one-time code for the grant's link (Web::LinkCodes), until it expires.
430
+ def link_code(issued)
431
+ ttl = [Time.iso8601(issued.grant.expires_at) - grants.now, 1].max
432
+ Web::LinkCodes.for_grants(grants.path).issue(kind: :grant, token: issued.token, ttl: ttl)
433
+ end
434
+
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).
439
+ def configured_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)
444
+ rescue Web::PublicUrl::Invalid
445
+ nil
446
+ end
447
+
406
448
  def local_web
407
449
  configured = Config.get("web.port").to_s.to_i
408
450
  configured = WEB_PORT unless configured.positive?
409
451
  recorded = Web::Running.port(path: Web::Running.path(state_dir: state_dir!))
410
452
  [recorded, configured].compact.uniq.each do |port|
411
453
  info = Web::InfoProbe.call("127.0.0.1", port, timeout: LOCAL_PROBE_TIMEOUT)
412
- return [info["lan"], port] if info.is_a?(Hash)
454
+ return LocalWeb.new(ip: own_address(info["lan"]), port: port) if info.is_a?(Hash)
413
455
  end
414
- [nil, configured]
456
+ LocalWeb.new(ip: nil, port: configured)
415
457
  end
416
458
 
417
459
  def dir = @state_dir ? { state_dir: @state_dir } : {}
@@ -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)
@@ -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