samagotchi 0.108.0 → 0.110.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -1
- data/bin/chi +5 -1
- data/docs/architecture.md +7 -2
- data/docs/cli.md +40 -1
- data/docs/configuration.md +177 -2
- data/docs/internals/engine.md +19 -4
- data/docs/internals/participants.md +28 -8
- data/docs/internals/prompt-caching.md +5 -0
- data/docs/internals/remote-sessions.md +6 -2
- 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/bridge.rb +25 -0
- 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 +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/proxy_trace.rb +45 -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 +52 -10
- 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 +246 -32
- data/lib/samagotchi/web/grants.rb +6 -3
- data/lib/samagotchi/web/info_probe.rb +5 -1
- data/lib/samagotchi/web/link_codes.rb +146 -0
- data/lib/samagotchi/web/participant_access.rb +9 -2
- data/lib/samagotchi/web/participant_review.rb +1 -1
- data/lib/samagotchi/web/participant_view.rb +12 -5
- data/lib/samagotchi/web/participants_admin.rb +13 -6
- data/lib/samagotchi/web/proxy_gate.rb +174 -0
- data/lib/samagotchi/web/public/app.js +35 -3
- data/lib/samagotchi/web/public/auth_lost.js +76 -0
- data/lib/samagotchi/web/public/bubbles.js +29 -7
- data/lib/samagotchi/web/public/data.js +29 -5
- 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 +47 -10
- 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/token_fragment.js +57 -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/public_url.rb +87 -0
- data/lib/samagotchi/web/running.rb +39 -4
- data/lib/samagotchi/web/server.rb +138 -22
- data/lib/samagotchi/web/tunnel/tailscale.rb +325 -0
- data/lib/samagotchi/web/tunnel.rb +133 -0
- data/lib/samagotchi/worker_sidecar.rb +4 -3
- metadata +12 -1
|
@@ -27,7 +27,7 @@ module Samagotchi
|
|
|
27
27
|
@ended_before = ended_before
|
|
28
28
|
@deadline = timeout && (ReplyWait.monotonic + timeout.to_f)
|
|
29
29
|
@turn = turn.to_s.empty? ? nil : turn.to_s
|
|
30
|
-
@session_dir = Session.session_dir(id, state_dir: state_dir)
|
|
30
|
+
@session_dir = Session.session_dir(id, state_dir: state_dir)
|
|
31
31
|
# Whether the turn's input file has been taken (latched): looked at
|
|
32
32
|
# before each load, so a running session seen after it is that turn
|
|
33
33
|
# or a later one, never the turn that ran when the message was sent.
|
|
@@ -40,6 +40,11 @@ module Samagotchi
|
|
|
40
40
|
@seen_own = false
|
|
41
41
|
@own_base = nil
|
|
42
42
|
@seen_running = false
|
|
43
|
+
# A turn seen running with its checkpoint mark (a wait with no
|
|
44
|
+
# turn:): last_turn's ended_at then and the input files claimed
|
|
45
|
+
# then, so its settle by a next worker (#settled_running) tells
|
|
46
|
+
# from a turn that ended or an input that runs next.
|
|
47
|
+
@running_look = nil
|
|
43
48
|
@gone_since = nil
|
|
44
49
|
end
|
|
45
50
|
|
|
@@ -216,8 +221,13 @@ module Samagotchi
|
|
|
216
221
|
|
|
217
222
|
if running?(session)
|
|
218
223
|
@seen_running = true
|
|
224
|
+
note_running(session)
|
|
219
225
|
return nil
|
|
220
226
|
end
|
|
227
|
+
if (settled = settled_running(session))
|
|
228
|
+
return settled
|
|
229
|
+
end
|
|
230
|
+
|
|
221
231
|
# It ran and is idle again with no new reply: canceled, failed or
|
|
222
232
|
# empty. Before it was ever seen running, idle means a
|
|
223
233
|
# file-delivered message its worker has not picked up yet. With a
|
|
@@ -254,6 +264,34 @@ module Samagotchi
|
|
|
254
264
|
nil
|
|
255
265
|
end
|
|
256
266
|
|
|
267
|
+
RunningLook = Data.define(:last_end, :claimed)
|
|
268
|
+
|
|
269
|
+
def note_running(session)
|
|
270
|
+
return unless session.interrupted_turn
|
|
271
|
+
|
|
272
|
+
@running_look = RunningLook.new(last_end: session.last_turn&.ended_at,
|
|
273
|
+
claimed: SessionInbox.claimed_input_files(@session_dir))
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# The turn seen running is gone with no turn's end: its worker died
|
|
277
|
+
# in it and the next one settled it (TurnCheckpoint.settle: idle, the
|
|
278
|
+
# mark gone, last_turn left alone), which ends a wait for it (chi
|
|
279
|
+
# send --wait with no message, a message merged into it as a steer)
|
|
280
|
+
# as canceled, worker_stopped, rather than at the timeout. Not while
|
|
281
|
+
# an input waits or a new one was claimed since (the sent message,
|
|
282
|
+
# unmerged, runs as its own turn next: its end is the answer), nor
|
|
283
|
+
# for a settle never seen running (a message sent to a session a dead
|
|
284
|
+
# worker left running goes in after the settle).
|
|
285
|
+
def settled_running(session)
|
|
286
|
+
look = @running_look
|
|
287
|
+
return nil unless look && session.status == Session::STATUS_IDLE && session.interrupted_turn.nil? &&
|
|
288
|
+
session.last_turn&.ended_at == look.last_end
|
|
289
|
+
return nil unless SessionInbox.find_new_input_files(@session_dir).empty?
|
|
290
|
+
return nil unless (SessionInbox.claimed_input_files(@session_dir) - look.claimed).empty?
|
|
291
|
+
|
|
292
|
+
Result.new(status: :no_reply, outcome: "canceled", cancel_reason: TurnCheckpoint::WORKER_STOPPED, session: session)
|
|
293
|
+
end
|
|
294
|
+
|
|
257
295
|
def owner_gone(session)
|
|
258
296
|
return nil unless @owner_grace
|
|
259
297
|
|
|
@@ -14,7 +14,7 @@ module Samagotchi
|
|
|
14
14
|
module RunOptions
|
|
15
15
|
# The flags an underscored spelling is refused for (--recap_base_url),
|
|
16
16
|
# with the config registry's.
|
|
17
|
-
KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token].freeze
|
|
17
|
+
KNOWN = %w[--prompt --resume --attach --shared --no-shared --model --thinking --toolkit --tool --no-tool --set --parent-answers --memory --mute --append-system-prompt --dir --llm-context --llm-context-apply --llm-context-budget --no-interrupt --non-interactive --verbose --no-default-input --port --open --new-token --tunnel].freeze
|
|
18
18
|
|
|
19
19
|
module_function
|
|
20
20
|
|
|
@@ -219,6 +219,9 @@ module Samagotchi
|
|
|
219
219
|
opts.on("--port PORT", "Port for `chi web` (default: 4567, env SAMAGOTCHI_WEB_PORT)") { |p| options[:web_port] = p.to_i; cli_overrides["web.port"] = p.to_s }
|
|
220
220
|
opts.on("--open", "Open browser for `chi web`") { options[:web_open] = true }
|
|
221
221
|
opts.on("--new-token", "`chi web`: replace the LAN access token (web.host: lan); links and QR codes made with the old one stop working") { options[:new_token] = true }
|
|
222
|
+
opts.on("--tunnel PROVIDER", %w[tailscale],
|
|
223
|
+
"`chi web`: reach it over https from your tailnet through Tailscale Serve (never Funnel), for this run; " \
|
|
224
|
+
"turned off on exit") { |v| options[:tunnel] = v }
|
|
222
225
|
opts.on("--scope VALUE", %w[project all],
|
|
223
226
|
"Which sessions `chi web` and `chi sessions list` show: project (the default: this folder's git project; " \
|
|
224
227
|
"every session outside a repo) or all") { |v| options[:scope] = v }
|
|
@@ -150,7 +150,7 @@ module Samagotchi
|
|
|
150
150
|
--llm-context-budget --toolkit --tool --no-tool --set --parent-answers --no-interrupt
|
|
151
151
|
--no-default-input --dir --version -h --help],
|
|
152
152
|
LOCAL_ONLY => %w[--no-shared --non-interactive -v --verbose --append-system-prompt --memory --mute --profile
|
|
153
|
-
--port --open --new-token --scope --web-markdown --no-web-markdown]
|
|
153
|
+
--port --open --new-token --tunnel --scope --web-markdown --no-web-markdown]
|
|
154
154
|
}.freeze
|
|
155
155
|
# A short run flag's name in a refusal.
|
|
156
156
|
RUN_WORDS = { "-v" => "--verbose" }.freeze
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "socket"
|
|
4
|
+
|
|
3
5
|
require_relative "../session"
|
|
4
6
|
require_relative "../session_access"
|
|
5
7
|
require_relative "../reply_wait"
|
|
@@ -18,6 +20,8 @@ require_relative "../participants"
|
|
|
18
20
|
require_relative "../config"
|
|
19
21
|
require_relative "../web/grants"
|
|
20
22
|
require_relative "../web/info_probe"
|
|
23
|
+
require_relative "../web/public_url"
|
|
24
|
+
require_relative "../web/link_codes"
|
|
21
25
|
require_relative "../web/running"
|
|
22
26
|
|
|
23
27
|
module Samagotchi
|
|
@@ -359,17 +363,25 @@ module Samagotchi
|
|
|
359
363
|
grants.list(session_id: id).map { |grant| ParticipantView.from_h(grant.public_h(grants.now)) }
|
|
360
364
|
end
|
|
361
365
|
|
|
362
|
-
# The link names this machine's chi web:
|
|
363
|
-
#
|
|
364
|
-
#
|
|
366
|
+
# The link names this machine's chi web: web.public_url from this
|
|
367
|
+
# machine's config (never what the port answers) when it is set, or
|
|
368
|
+
# the one a running chi web --tunnel recorded (the code in the
|
|
369
|
+
# fragment, as chi web's own invite), else its LAN address
|
|
370
|
+
# and port when one runs on the LAN (the port it recorded, else
|
|
371
|
+
# web.port), else 127.0.0.1 (+lan+ false: the link opens on this
|
|
372
|
+
# machine only).
|
|
365
373
|
# @return [Invitation]
|
|
366
374
|
# @raise [ArgumentError] no such session
|
|
367
375
|
# @raise [BadRequest] a bad name, kind or ttl, an archived session
|
|
368
376
|
def invite(id, name:, kind:, ttl: nil, cap: nil)
|
|
369
377
|
issued = Participants.invite(grants, session_id: id, name: name, kind: kind, ttl: ttl, cap: cap, state_dir: state_dir!)
|
|
370
|
-
|
|
378
|
+
public_url = configured_public_url
|
|
379
|
+
web = public_url ? nil : local_web
|
|
380
|
+
link = if public_url then public_url.link(link_code(issued), "s=#{id}")
|
|
381
|
+
else "http://#{web.ip || "127.0.0.1"}:#{web.port}/?token=#{issued.token}#s=#{id}"
|
|
382
|
+
end
|
|
371
383
|
Invitation.new(participant: ParticipantView.from_h(issued.grant.public_h(grants.now)), token: issued.token,
|
|
372
|
-
link:
|
|
384
|
+
link: link, lan: !public_url.nil? || !web.ip.nil?,
|
|
373
385
|
warning: ReviewStart.share_warning(Session.load(id, **dir)))
|
|
374
386
|
rescue Web::Grants::Invalid => e
|
|
375
387
|
raise BadRequest.new(e.message, code: "invalid_participant")
|
|
@@ -400,18 +412,48 @@ module Samagotchi
|
|
|
400
412
|
WEB_PORT = 4567
|
|
401
413
|
LOCAL_PROBE_TIMEOUT = 1.0
|
|
402
414
|
|
|
403
|
-
#
|
|
404
|
-
# the port the running one recorded
|
|
405
|
-
# answers there, else config's web.port.
|
|
415
|
+
# Where this machine's chi web answers (GET /api/info): its LAN ip
|
|
416
|
+
# (nil without one) and the port the running one recorded
|
|
417
|
+
# (Web::Running) when a chi web answers there, else config's web.port.
|
|
418
|
+
LocalWeb = Data.define(:ip, :port)
|
|
419
|
+
|
|
420
|
+
# The LAN address a probe answered, only if it is one of this
|
|
421
|
+
# machine's: whatever listens on the port may be another user's
|
|
422
|
+
# listener naming its own (nil then: the link names 127.0.0.1).
|
|
423
|
+
def own_address(ip)
|
|
424
|
+
return nil unless ip.is_a?(String)
|
|
425
|
+
|
|
426
|
+
Socket.ip_address_list.any? { |addr| addr.ip_address == ip } ? ip : nil
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
# A one-time code for the grant's link (Web::LinkCodes), until it expires.
|
|
430
|
+
def link_code(issued)
|
|
431
|
+
ttl = [Time.iso8601(issued.grant.expires_at) - grants.now, 1].max
|
|
432
|
+
Web::LinkCodes.for_grants(grants.path).issue(kind: :grant, token: issued.token, ttl: ttl)
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# web.public_url as this machine's config has it (nil when not a
|
|
436
|
+
# valid one: chi web refuses to start on that), else the one the
|
|
437
|
+
# running chi web recorded (chi web --tunnel's), only while that same
|
|
438
|
+
# process answers on its port (Web::Running.live_public_url).
|
|
439
|
+
def configured_public_url
|
|
440
|
+
configured = Config.get("web.public_url")
|
|
441
|
+
return Web::Running.live_public_url(path: Web::Running.path(state_dir: state_dir!)) if configured.to_s.strip.empty?
|
|
442
|
+
|
|
443
|
+
Web::PublicUrl.from_config(url: configured)
|
|
444
|
+
rescue Web::PublicUrl::Invalid
|
|
445
|
+
nil
|
|
446
|
+
end
|
|
447
|
+
|
|
406
448
|
def local_web
|
|
407
449
|
configured = Config.get("web.port").to_s.to_i
|
|
408
450
|
configured = WEB_PORT unless configured.positive?
|
|
409
451
|
recorded = Web::Running.port(path: Web::Running.path(state_dir: state_dir!))
|
|
410
452
|
[recorded, configured].compact.uniq.each do |port|
|
|
411
453
|
info = Web::InfoProbe.call("127.0.0.1", port, timeout: LOCAL_PROBE_TIMEOUT)
|
|
412
|
-
return
|
|
454
|
+
return LocalWeb.new(ip: own_address(info["lan"]), port: port) if info.is_a?(Hash)
|
|
413
455
|
end
|
|
414
|
-
|
|
456
|
+
LocalWeb.new(ip: nil, port: configured)
|
|
415
457
|
end
|
|
416
458
|
|
|
417
459
|
def dir = @state_dir ? { state_dir: @state_dir } : {}
|
|
@@ -371,10 +371,14 @@ module Samagotchi
|
|
|
371
371
|
claimed_input?(session_dir, id) ? [:claimed, nil] : [:none, nil]
|
|
372
372
|
end
|
|
373
373
|
|
|
374
|
-
#
|
|
375
|
-
#
|
|
374
|
+
# The claimed input files (*.json.processing: a turn's, now or left by
|
|
375
|
+
# a crash, or a removal's before it deletes it).
|
|
376
|
+
# @return [Array<String>] their paths
|
|
377
|
+
def self.claimed_input_files(session_dir) = Dir.glob(File.join(session_dir, INPUT_DIR, "*.json.processing"))
|
|
378
|
+
|
|
379
|
+
# Whether a claimed input file carries +id+.
|
|
376
380
|
def self.claimed_input?(session_dir, id)
|
|
377
|
-
|
|
381
|
+
claimed_input_files(session_dir).any? do |file|
|
|
378
382
|
data = JSON.parse(File.read(file))
|
|
379
383
|
data.is_a?(Hash) && data["enqueued_id"] == id
|
|
380
384
|
rescue JSON::ParserError, SystemCallError
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "client_id"
|
|
4
4
|
require_relative "participant_name"
|
|
5
|
+
require_relative "message_time"
|
|
5
6
|
|
|
6
7
|
module Samagotchi
|
|
7
8
|
# A plugin's steer: text a plugin puts into the running turn (Engine#steer),
|
|
@@ -53,15 +54,16 @@ module Samagotchi
|
|
|
53
54
|
|
|
54
55
|
# The messages it appends: the merged input first (one message per
|
|
55
56
|
# sender run, marked kind input, source: when not the user's), then
|
|
56
|
-
# each steer
|
|
57
|
-
|
|
57
|
+
# each steer; all stamped with +at+ (MessageTime::KEY), the drain's
|
|
58
|
+
# wall time.
|
|
59
|
+
def messages(at: MessageTime.iso(MessageTime.now))
|
|
58
60
|
list = inputs.map do |input|
|
|
59
61
|
message = { role: "user", kind: INPUT_KIND }
|
|
60
62
|
message[:source] = input[:source] if input[:source]
|
|
61
63
|
message.merge!(input[:mark]) if input[:mark]
|
|
62
64
|
message.merge(content: input[:content], **Steer.author_fields(input[:author], input[:author_name]))
|
|
63
65
|
end
|
|
64
|
-
list + steers.map { |steer| Steer.message(**steer) }
|
|
66
|
+
(list + steers.map { |steer| Steer.message(**steer) }).map { |message| MessageTime.stamp(message, at) }
|
|
65
67
|
end
|
|
66
68
|
|
|
67
69
|
# The :pending_input_merged fields beside iteration and answer;
|
|
@@ -14,6 +14,7 @@ require_relative "line_reader"
|
|
|
14
14
|
require_relative "question_prompt"
|
|
15
15
|
require_relative "reline_seam"
|
|
16
16
|
require_relative "version_lines"
|
|
17
|
+
require_relative "gap_clock"
|
|
17
18
|
require_relative "participant_commands"
|
|
18
19
|
require_relative "local_link"
|
|
19
20
|
require_relative "../bridge/turn_accumulator"
|
|
@@ -100,6 +101,7 @@ module Samagotchi
|
|
|
100
101
|
# @param default_input [Boolean] type SAMAGOTCHI_DEFAULT_INPUT into
|
|
101
102
|
# the first read (a new session with no -p, as the REPL)
|
|
102
103
|
# @param clock [#call] monotonic seconds (the Ctrl-C detach window)
|
|
104
|
+
# @param now [#call] the wall clock, a Time (the gap dividers)
|
|
103
105
|
# @param delete_session [#call] session id -> deletes it (/exit --delete)
|
|
104
106
|
# @param archive_session [#call] session id -> archives it (/archive);
|
|
105
107
|
# SessionManager.archive_session's result
|
|
@@ -114,7 +116,7 @@ module Samagotchi
|
|
|
114
116
|
# +client+ and the keywords that make a LocalLink
|
|
115
117
|
def initialize(screen:, client_id:, client: nil, first_prompt: nil, first_command: nil, no_interrupt: false,
|
|
116
118
|
default_input: false, wait_at_eof: false, as_parent: false,
|
|
117
|
-
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
119
|
+
clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, now: -> { MessageTime.now },
|
|
118
120
|
delete_session: ->(id) { SessionManager.delete_session(id, stop: true, wait: LocalLink::DELETE_WAIT) },
|
|
119
121
|
archive_session: ->(id) { SessionManager.archive_session(id, wait: LocalLink::DELETE_WAIT) },
|
|
120
122
|
installed_version: -> { InstalledVersions.new.newest },
|
|
@@ -152,6 +154,8 @@ module Samagotchi
|
|
|
152
154
|
@no_interrupt = no_interrupt
|
|
153
155
|
@no_default_input = !default_input
|
|
154
156
|
@clock = clock
|
|
157
|
+
# The session's last activity, for the gap dividers (GapClock).
|
|
158
|
+
@gaps = GapClock.new(now: now)
|
|
155
159
|
@last_idle_interrupt_at = nil
|
|
156
160
|
@next_input_prefill = nil
|
|
157
161
|
# Prompts this run sent, by enqueued_id: only those come back into
|
|
@@ -208,7 +212,12 @@ module Samagotchi
|
|
|
208
212
|
end
|
|
209
213
|
read = input || method(:read_input_line)
|
|
210
214
|
# A command typed after /unqueue's text echoes as the command alone.
|
|
211
|
-
|
|
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
|