samagotchi 0.109.0 → 0.111.0

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