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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +9 -6
- data/docs/cli.md +43 -1
- data/docs/configuration.md +140 -3
- data/docs/internals/engine.md +58 -14
- data/docs/internals/participants.md +7 -4
- data/docs/internals/prompt-caching.md +5 -0
- data/docs/internals/remote-sessions.md +3 -3
- data/docs/internals/web-push.md +200 -0
- data/docs/internals/working-set.md +7 -6
- data/docs/plugins.md +1 -1
- data/docs/sessions.md +4 -2
- 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/config.rb +4 -0
- data/lib/samagotchi/context_note.rb +7 -3
- data/lib/samagotchi/engine.rb +166 -499
- data/lib/samagotchi/engine_snapshots.rb +223 -0
- data/lib/samagotchi/event_fanout.rb +236 -0
- 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/model_binding.rb +138 -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/send_command/wait_target.rb +10 -3
- data/lib/samagotchi/send_command.rb +2 -1
- data/lib/samagotchi/session.rb +12 -3
- data/lib/samagotchi/session_access/contract.rb +1 -1
- data/lib/samagotchi/session_access/local.rb +11 -5
- data/lib/samagotchi/session_access/remote.rb +2 -1
- data/lib/samagotchi/session_access.rb +7 -3
- data/lib/samagotchi/session_inbox.rb +7 -3
- data/lib/samagotchi/session_manager.rb +5 -0
- 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/turn_endings.rb +137 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +28 -1369
- data/lib/samagotchi/web/grants.rb +3 -12
- data/lib/samagotchi/web/message_display.rb +274 -0
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/private_files.rb +31 -0
- data/lib/samagotchi/web/public/app.js +41 -9
- data/lib/samagotchi/web/public/apple-touch-icon.png +0 -0
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/format.js +72 -6
- data/lib/samagotchi/web/public/icons/chi-180.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-192.png +0 -0
- data/lib/samagotchi/web/public/icons/chi-512.png +0 -0
- data/lib/samagotchi/web/public/index.html +6 -0
- data/lib/samagotchi/web/public/manifest.webmanifest +13 -0
- data/lib/samagotchi/web/public/notifications.js +137 -3
- data/lib/samagotchi/web/public/notify.js +1 -1
- 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/push.js +95 -0
- data/lib/samagotchi/web/public/stage_view.js +8 -0
- data/lib/samagotchi/web/public/stream_handlers.js +32 -4
- data/lib/samagotchi/web/public/sw.js +101 -0
- 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 +25 -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/push/attention.rb +105 -0
- data/lib/samagotchi/web/push/client.rb +103 -0
- data/lib/samagotchi/web/push/crypto.rb +126 -0
- data/lib/samagotchi/web/push/notifier.rb +230 -0
- data/lib/samagotchi/web/push/store.rb +285 -0
- data/lib/samagotchi/web/push.rb +73 -0
- data/lib/samagotchi/web/push_routes.rb +99 -0
- data/lib/samagotchi/web/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +112 -32
- data/lib/samagotchi/web/session_control.rb +441 -0
- data/lib/samagotchi/web/session_create.rb +384 -0
- data/lib/samagotchi/web/session_show.rb +411 -0
- data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- data/lib/samagotchi/worker.rb +3 -2
- 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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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.
|