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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +1 -1
- data/docs/cli.md +40 -1
- data/docs/configuration.md +97 -3
- data/docs/internals/engine.md +19 -4
- data/docs/internals/participants.md +6 -3
- data/docs/internals/prompt-caching.md +5 -0
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +3 -1
- data/docs/sub-agent.md +1 -1
- data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/engine.rb +20 -9
- data/lib/samagotchi/idle_recap.rb +1 -1
- data/lib/samagotchi/idle_reminders.rb +1 -1
- data/lib/samagotchi/kernel_loop.rb +12 -7
- data/lib/samagotchi/llm/chat_loop.rb +9 -2
- data/lib/samagotchi/message_time.rb +104 -0
- data/lib/samagotchi/reminder_queue.rb +20 -3
- data/lib/samagotchi/reply_wait/poll.rb +39 -1
- data/lib/samagotchi/run_options.rb +4 -1
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +11 -5
- data/lib/samagotchi/session_inbox.rb +7 -3
- data/lib/samagotchi/steer.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +70 -9
- data/lib/samagotchi/terminal_ui/between_turns.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +13 -5
- data/lib/samagotchi/terminal_ui/gap_clock.rb +78 -0
- data/lib/samagotchi/terminal_ui/reline_seam.rb +20 -2
- data/lib/samagotchi/terminal_ui.rb +40 -5
- data/lib/samagotchi/tool_activity.rb +11 -0
- data/lib/samagotchi/turn_checkpoint.rb +6 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +15 -6
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/public/app.js +11 -3
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/format.js +72 -6
- data/lib/samagotchi/web/public/index.html +2 -0
- data/lib/samagotchi/web/public/participant.html +2 -0
- data/lib/samagotchi/web/public/participant.js +30 -7
- data/lib/samagotchi/web/public/participant_model.js +25 -2
- data/lib/samagotchi/web/public/stream_handlers.js +32 -4
- data/lib/samagotchi/web/public/time_gaps.js +98 -0
- data/lib/samagotchi/web/public/tokens.css +2 -2
- data/lib/samagotchi/web/public/turn_events.js +3 -1
- data/lib/samagotchi/web/public/turn_html.js +15 -8
- data/lib/samagotchi/web/public/turn_view.js +8 -3
- data/lib/samagotchi/web/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +87 -31
- data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- metadata +6 -1
|
@@ -26,7 +26,9 @@ module Samagotchi
|
|
|
26
26
|
# @param renderer [EventRenderer] draws cards
|
|
27
27
|
# @param turn_running [#call] whether a turn runs now
|
|
28
28
|
# @param quiet [Boolean] a --non-interactive run: no init lines
|
|
29
|
-
|
|
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
|
-
@
|
|
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,
|
|
517
|
-
#
|
|
518
|
-
|
|
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? ? "
|
|
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
|
-
|
|
233
|
-
text
|
|
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
|
-
|
|
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.
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -38,6 +38,7 @@ require_relative "../version"
|
|
|
38
38
|
require_relative "../config"
|
|
39
39
|
require_relative "../output_formatter"
|
|
40
40
|
require_relative "../image_store"
|
|
41
|
+
require_relative "../message_time"
|
|
41
42
|
require_relative "../context_note"
|
|
42
43
|
require_relative "../context_sources"
|
|
43
44
|
require_relative "../context_providers"
|
|
@@ -2683,11 +2684,12 @@ module Samagotchi
|
|
|
2683
2684
|
# a live worker's (it counts against its Engine's messages), else
|
|
2684
2685
|
# recap.json against the session file. A stopped session gets its
|
|
2685
2686
|
# recap without a worker being woken.
|
|
2686
|
-
# @return [Hash, nil] {text:, turns_since:}
|
|
2687
|
+
# @return [Hash, nil] {text:, turns_since:, created_at:} (created_at:
|
|
2688
|
+
# when it was written, UTC ISO; absent from an older recap)
|
|
2687
2689
|
def saved_recap_for(id, session, turn_snapshot)
|
|
2688
2690
|
if turn_snapshot
|
|
2689
2691
|
saved = turn_snapshot["saved_recap"]
|
|
2690
|
-
return saved && { text: saved["text"], turns_since: saved["turns_since"].to_i }
|
|
2692
|
+
return saved && { text: saved["text"], turns_since: saved["turns_since"].to_i, created_at: saved["created_at"] }.compact
|
|
2691
2693
|
end
|
|
2692
2694
|
|
|
2693
2695
|
saved = RecapStore.read(@session_class.session_dir(id, state_dir: default_state_dir))
|
|
@@ -2695,7 +2697,7 @@ module Samagotchi
|
|
|
2695
2697
|
|
|
2696
2698
|
messages = Array(session.messages)
|
|
2697
2699
|
since = messages.drop(saved[:covered].to_i).count { |m| Samagotchi::Steer.turn_prompt?(m) }
|
|
2698
|
-
{ text: saved[:text], turns_since: since }
|
|
2700
|
+
{ text: saved[:text], turns_since: since, created_at: saved[:created_at] }.compact
|
|
2699
2701
|
rescue StandardError
|
|
2700
2702
|
nil
|
|
2701
2703
|
end
|
|
@@ -2732,7 +2734,7 @@ module Samagotchi
|
|
|
2732
2734
|
role = (m[:role] || m["role"]).to_s
|
|
2733
2735
|
content = (m[:content] || m["content"]).to_s
|
|
2734
2736
|
if Samagotchi::ContextNote.note?(m)
|
|
2735
|
-
note = { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m) }
|
|
2737
|
+
note = { role: "note", content: Samagotchi::ContextNote.text_of(m), label: Samagotchi::ContextNote.label_of(m), **time_of(m) }
|
|
2736
2738
|
# A context wake turn's note starts that turn (Steer.turn_prompt?):
|
|
2737
2739
|
# the page opens the turn with it and pairs it with its record.
|
|
2738
2740
|
if Samagotchi::Steer.turn_prompt?(m)
|
|
@@ -2745,7 +2747,7 @@ module Samagotchi
|
|
|
2745
2747
|
# A plugin's steer: a row of the step that answered it, which the
|
|
2746
2748
|
# page finds by `step`.
|
|
2747
2749
|
if Samagotchi::Steer.steer?(m)
|
|
2748
|
-
filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index) }
|
|
2750
|
+
filtered << { role: "steer", content: content, source: (m[:source] || m["source"]).to_s, step: steer_step(list, index), **time_of(m) }
|
|
2749
2751
|
next
|
|
2750
2752
|
end
|
|
2751
2753
|
# A turn that ended with no answer (TurnNote.empty's marker): its
|
|
@@ -2768,7 +2770,7 @@ module Samagotchi
|
|
|
2768
2770
|
norm_role = "user" if norm_role == "user"
|
|
2769
2771
|
next unless %w[user assistant].include?(norm_role)
|
|
2770
2772
|
|
|
2771
|
-
message = { role: norm_role, content: stripped }
|
|
2773
|
+
message = { role: norm_role, content: stripped, **time_of(m) }
|
|
2772
2774
|
# Merged into the running turn (Steer::INPUT_KIND): part of it,
|
|
2773
2775
|
# read by step +step+; a wake turn's report starts its turn
|
|
2774
2776
|
# (Steer.turn_prompt?). Its sender (a delegate report, chi send):
|
|
@@ -2808,6 +2810,13 @@ module Samagotchi
|
|
|
2808
2810
|
[]
|
|
2809
2811
|
end
|
|
2810
2812
|
|
|
2813
|
+
# The entry's wall time (MessageTime: the page's hover and gap
|
|
2814
|
+
# dividers) as {at:}, or nothing for an entry saved without one.
|
|
2815
|
+
def time_of(message)
|
|
2816
|
+
at = Samagotchi::MessageTime.of(message)
|
|
2817
|
+
at.is_a?(String) && !at.empty? ? { at: at } : {}
|
|
2818
|
+
end
|
|
2819
|
+
|
|
2811
2820
|
# A no-answer turn's marker as display items: each empty step that
|
|
2812
2821
|
# has something to show (its thinking) as a text-less assistant step
|
|
2813
2822
|
# (only with +parts+, as other text-less steps), then
|
|
@@ -108,7 +108,7 @@ module Samagotchi
|
|
|
108
108
|
def turn_started(event, viewer)
|
|
109
109
|
origin = TurnOrigin.of(field(event, :origin))
|
|
110
110
|
author = field(event, :author) || origin.author
|
|
111
|
-
out = { "type" => "turn_started", **id_fields(event) }
|
|
111
|
+
out = { "type" => "turn_started", **id_fields(event), **time_field(event) }
|
|
112
112
|
out.merge!(author_fields(author, field(event, :author_name), viewer))
|
|
113
113
|
prompt = field(event, :prompt)
|
|
114
114
|
if field(event, :continue)
|
|
@@ -164,8 +164,8 @@ module Samagotchi
|
|
|
164
164
|
# analytics records' +automatic+, and the running one's): their
|
|
165
165
|
# prompt is shown as an automatic turn, without its text
|
|
166
166
|
# @return [Array<Hash>] {role: "user", content, turn_id, author,
|
|
167
|
-
# author_name, mine, automatic, images} and {role: "assistant",
|
|
168
|
-
# content, tools: [{tool, path}]}; no notes, steers, automatic
|
|
167
|
+
# author_name, mine, automatic, images, at} and {role: "assistant",
|
|
168
|
+
# content, tools: [{tool, path}], at}; no notes, steers, automatic
|
|
169
169
|
# input or empty-answer markers. html is added by the caller, from
|
|
170
170
|
# this (redacted) content.
|
|
171
171
|
def messages(shown, viewer, automatic: [])
|
|
@@ -194,7 +194,7 @@ module Samagotchi
|
|
|
194
194
|
merged = field(message, :merged)
|
|
195
195
|
return nil unless turn_id || merged
|
|
196
196
|
|
|
197
|
-
out = { "role" => "user" }
|
|
197
|
+
out = { "role" => "user", **time_field(message) }
|
|
198
198
|
if turn_id && automatic.include?(turn_id.to_s)
|
|
199
199
|
out.merge!("content" => "", "automatic" => true, "turn_id" => turn_id.to_s)
|
|
200
200
|
return out
|
|
@@ -215,7 +215,7 @@ module Samagotchi
|
|
|
215
215
|
content = field(message, :content).to_s
|
|
216
216
|
return nil if content.strip.empty? && rows.empty?
|
|
217
217
|
|
|
218
|
-
out = { "role" => "assistant", "content" => content }
|
|
218
|
+
out = { "role" => "assistant", "content" => content, **time_field(message) }
|
|
219
219
|
out["tools"] = rows unless rows.empty?
|
|
220
220
|
out
|
|
221
221
|
end
|
|
@@ -287,6 +287,13 @@ module Samagotchi
|
|
|
287
287
|
out
|
|
288
288
|
end
|
|
289
289
|
|
|
290
|
+
# When it happened (MessageTime's stamp, a turn's start): the page's
|
|
291
|
+
# hover and gap dividers. A wall time only, nothing of what was done.
|
|
292
|
+
def time_field(item)
|
|
293
|
+
at = field(item, :at)
|
|
294
|
+
at.is_a?(String) && !at.empty? ? { "at" => at } : {}
|
|
295
|
+
end
|
|
296
|
+
|
|
290
297
|
def id_fields(event)
|
|
291
298
|
out = seq(event)
|
|
292
299
|
turn_id = field(event, :turn_id)
|
|
@@ -46,6 +46,7 @@ import { createCardsPlace } from "./cards_place.js";
|
|
|
46
46
|
import { createStageView } from "./stage_view.js";
|
|
47
47
|
import { createWorkingSet, groupItems } from "./working_set_view.js";
|
|
48
48
|
import { turnGroups } from "./timing.js";
|
|
49
|
+
import { createGapClock, lastActivity } from "./time_gaps.js";
|
|
49
50
|
import { createMarkdownPlace } from "./markdown_place.js";
|
|
50
51
|
import { openerFill, openTitle, restoredComposer } from "./chains.js";
|
|
51
52
|
import { createTaskStopper } from "./task_stop.js";
|
|
@@ -371,8 +372,11 @@ const bubbles = createBubbles({
|
|
|
371
372
|
});
|
|
372
373
|
const {
|
|
373
374
|
removeHintIfEmpty, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap, markRecapStale,
|
|
374
|
-
showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
|
|
375
|
+
showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble, addTimeGap,
|
|
375
376
|
} = bubbles;
|
|
377
|
+
// The open session's last activity, for the live gap dividers
|
|
378
|
+
// (time_gaps.js): renderHistory seeds it, the stream moves it.
|
|
379
|
+
const gapClock = createGapClock();
|
|
376
380
|
// The plugin cards and a snapshot's notices in the history (cards_place.js).
|
|
377
381
|
const cards = createCardsPlace({
|
|
378
382
|
historyEl,
|
|
@@ -947,7 +951,7 @@ function renderSessionView(data, { kept = [] } = {}) {
|
|
|
947
951
|
if (!loadWarned("plugins")) showGuardrailWarning(data.plugin_warning, "plugins");
|
|
948
952
|
// The recap saved with the session (also a stopped one's), else a live
|
|
949
953
|
// worker's from before saved_recap existed.
|
|
950
|
-
if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since);
|
|
954
|
+
if (data.saved_recap) showRecap(data.saved_recap.text, data.saved_recap.turns_since, data.saved_recap.created_at || null);
|
|
951
955
|
else showRecap(data.recap);
|
|
952
956
|
// A live worker's last cards and between-turns notices, where they
|
|
953
957
|
// arrived; the running turn's go into its step once it is drawn.
|
|
@@ -1004,12 +1008,15 @@ function seedWorkingSet(data) {
|
|
|
1004
1008
|
}
|
|
1005
1009
|
|
|
1006
1010
|
function renderHistory(items) {
|
|
1011
|
+
gapClock.seed(null);
|
|
1007
1012
|
if (!items.length) {
|
|
1008
1013
|
historyEl.replaceChildren(hintEl("No output yet. Send a message."));
|
|
1009
1014
|
return;
|
|
1010
1015
|
}
|
|
1011
1016
|
if (typeof items[0] === "object" && items[0].role) {
|
|
1012
1017
|
historyEl.replaceChildren(turnHistoryNodes(items, current.timing, { thumbs: (images) => thumbsTemplate(selected, images), workingSet: !!workingSet }));
|
|
1018
|
+
// The live dividers count from the history's last activity.
|
|
1019
|
+
gapClock.seed(lastActivity(turnGroups(items, current.timing), items));
|
|
1013
1020
|
linkTextRefs();
|
|
1014
1021
|
} else {
|
|
1015
1022
|
historyEl.replaceChildren(...items.map((c) => elementOf(outputBubbleTemplate(c))));
|
|
@@ -1416,7 +1423,8 @@ const streamHandlers = createStreamHandlers({
|
|
|
1416
1423
|
setUsedMemories, onInitEvent, findInTurnRoots, applyFinalMarkdown,
|
|
1417
1424
|
// bubbles.js
|
|
1418
1425
|
applyPromptOps, showSteers, markRecapStale, appendChunk, makeUserBubble, addEndBubble, commandBubble,
|
|
1419
|
-
addCommandBubble, addNoteBubble, showRecap, showGuardrailWarning, showHookNotice,
|
|
1426
|
+
addCommandBubble, addNoteBubble, showRecap, showGuardrailWarning, showHookNotice, addTimeGap,
|
|
1427
|
+
gapClock,
|
|
1420
1428
|
// cards_place.js
|
|
1421
1429
|
upsertCard, refreshCardBodies,
|
|
1422
1430
|
syncModelPick: startPage.commandRan,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// notices, plugins' steers, command and note bubbles. The markup is lit
|
|
5
5
|
// (turn_html.js elementOf); createBubbles puts them in the history.
|
|
6
6
|
import { html, nothing } from "./vendor/lit-html.js";
|
|
7
|
-
import { normalize, recapLabel, recapPlace } from "./format.js";
|
|
7
|
+
import { fullTime, normalize, recapLabel, recapPlace } from "./format.js";
|
|
8
8
|
import { BADGES, elementOf, noteBubbleTemplate, stateBadgeTemplate, steerRowTemplate, userBubbleTemplate } from "./turn_html.js";
|
|
9
9
|
import { thumbsTemplate } from "./images.js";
|
|
10
10
|
import { appendAboveLiveTiming, STOPPED_LINE_CLASS } from "./timing.js";
|
|
@@ -12,6 +12,7 @@ import { keepFollowing } from "./scroll.js";
|
|
|
12
12
|
import { linkedLine } from "./notice_links.js";
|
|
13
13
|
import { hookNoticeLabel, isReportClient, promptOps } from "./turn_events.js";
|
|
14
14
|
import { drawCommandBubble } from "./commands.js";
|
|
15
|
+
import { TIME_GAP_CLASS, timeGapEl } from "./time_gaps.js";
|
|
15
16
|
|
|
16
17
|
export function removeQueuedBubble(bubble) {
|
|
17
18
|
if (!bubble) return;
|
|
@@ -49,8 +50,11 @@ export function endBubbleTemplate(text, { stopped = false } = {}) {
|
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
// The idle recap, open; a stale one (+turnsSince+ > 0) says it is earlier.
|
|
52
|
-
|
|
53
|
-
|
|
53
|
+
// +createdAt+: when it was written (its label's time, kept for the stale
|
|
54
|
+
// label as data-created-at).
|
|
55
|
+
export function recapTemplate(text, turnsSince = 0, createdAt = null, now = Date.now()) {
|
|
56
|
+
const created = typeof createdAt === "string" && createdAt ? createdAt : nothing;
|
|
57
|
+
return html`<details class=${turnsSince > 0 ? "bubble recap stale" : "bubble recap"} data-created-at=${created} open><summary>${recapLabel(turnsSince, false, createdAt, now)}</summary><div class="recap-body">${text}</div></details>`;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
// A guardrail hook or rule file that failed to load (once per worker), or
|
|
@@ -228,14 +232,16 @@ export function createBubbles({
|
|
|
228
232
|
// the end of the history. A turn starting leaves it in place, marked
|
|
229
233
|
// earlier; only a new recap replaces it.
|
|
230
234
|
|
|
231
|
-
function showRecap(text, turnsSince = 0) {
|
|
235
|
+
function showRecap(text, turnsSince = 0, createdAt = null) {
|
|
232
236
|
if (!text) return;
|
|
233
237
|
removeRecap();
|
|
234
|
-
const details = elementOf(recapTemplate(text, turnsSince));
|
|
238
|
+
const details = elementOf(recapTemplate(text, turnsSince, createdAt));
|
|
235
239
|
// A stale one sits where it was written, above the turns it doesn't cover.
|
|
236
240
|
// Never in the stage: a recap is between turns.
|
|
237
241
|
turnDom.settleFold?.();
|
|
238
|
-
const
|
|
242
|
+
const prompt = recapPlace(turnPromptBubbles(historyEl), turnsSince);
|
|
243
|
+
// Above that turn's gap divider too: the recap was written before the quiet stretch ended.
|
|
244
|
+
const before = prompt?.previousElementSibling?.matches(`.${TIME_GAP_CLASS}`) ? prompt.previousElementSibling : prompt;
|
|
239
245
|
keepFollowing(historyEl, () => (before ? historyEl.insertBefore(details, before) : appendAboveLiveTiming(historyEl, details, liveTurn.timing.el)));
|
|
240
246
|
}
|
|
241
247
|
|
|
@@ -244,7 +250,7 @@ export function createBubbles({
|
|
|
244
250
|
historyEl.querySelectorAll(".bubble.recap:not(.stale)").forEach((el) => {
|
|
245
251
|
el.classList.add("stale");
|
|
246
252
|
const summary = el.querySelector("summary");
|
|
247
|
-
if (summary) summary.textContent = recapLabel(0, true);
|
|
253
|
+
if (summary) summary.textContent = recapLabel(0, true, el.dataset.createdAt || null);
|
|
248
254
|
});
|
|
249
255
|
}
|
|
250
256
|
|
|
@@ -282,6 +288,9 @@ export function createBubbles({
|
|
|
282
288
|
const id = selected();
|
|
283
289
|
if (id) div.dataset.keptFor = id;
|
|
284
290
|
drawCommandBubble(div, view);
|
|
291
|
+
// When it happened (a reminder's at): the hover, as a reload's bubbles.
|
|
292
|
+
const when = fullTime(view.at);
|
|
293
|
+
if (when) div.title = when;
|
|
285
294
|
placeFollowing(div);
|
|
286
295
|
}
|
|
287
296
|
|
|
@@ -295,8 +304,21 @@ export function createBubbles({
|
|
|
295
304
|
placeFollowing(elementOf(noteBubbleTemplate(note)));
|
|
296
305
|
}
|
|
297
306
|
|
|
307
|
+
// A quiet stretch passed (time_gaps.js): its divider above +before+ (a
|
|
308
|
+
// turn's prompt bubble, wherever it sits), else at the end.
|
|
309
|
+
function addTimeGap(text, { before = null } = {}) {
|
|
310
|
+
if (!text) return null;
|
|
311
|
+
const el = timeGapEl(text);
|
|
312
|
+
if (before?.parentNode) {
|
|
313
|
+
keepFollowing(historyEl, () => before.parentNode.insertBefore(el, before));
|
|
314
|
+
return el;
|
|
315
|
+
}
|
|
316
|
+
return placeFollowing(el);
|
|
317
|
+
}
|
|
318
|
+
|
|
298
319
|
return {
|
|
299
320
|
removeHintIfEmpty, placeFollowing, appendChunk, addEndBubble, makeUserBubble, bubbleById, applyPromptOps, showRecap,
|
|
300
321
|
markRecapStale, showGuardrailWarning, showSteers, showHookNotice, addCommandBubble, commandBubble, addNoteBubble,
|
|
322
|
+
addTimeGap,
|
|
301
323
|
};
|
|
302
324
|
}
|