samagotchi 0.20.0 → 0.21.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 +48 -1
- data/docs/architecture.md +2 -1
- data/docs/cli.md +2 -1
- data/docs/plugins.md +22 -8
- data/docs/sessions.md +2 -2
- data/lib/samagotchi/bridge/sse_writer.rb +32 -0
- data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
- data/lib/samagotchi/bridge.rb +16 -4
- data/lib/samagotchi/bridge_client.rb +33 -9
- data/lib/samagotchi/bundle_command.rb +6 -0
- data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
- data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +15 -2
- data/lib/samagotchi/events.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/openai_chat.rb +19 -5
- data/lib/samagotchi/log_subscriber.rb +4 -2
- data/lib/samagotchi/session_manager.rb +26 -9
- data/lib/samagotchi/session_metrics.rb +6 -4
- data/lib/samagotchi/steer.rb +1 -1
- data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
- data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
- data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
- data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/public/app.js +34 -7
- data/lib/samagotchi/web/public/index.html +7 -0
- data/lib/samagotchi/web/public/notifications.js +8 -3
- data/lib/samagotchi/web/public/notify.js +49 -7
- data/lib/samagotchi/web/public/timing.js +5 -3
- data/lib/samagotchi/web/public/turn_events.js +16 -0
- metadata +1 -1
data/lib/samagotchi/engine.rb
CHANGED
|
@@ -1637,12 +1637,25 @@ module Samagotchi
|
|
|
1637
1637
|
def stopped_before_model(turn)
|
|
1638
1638
|
reason = turn.controller.reason
|
|
1639
1639
|
end_turn(turn, "canceled") do |seconds|
|
|
1640
|
-
[nil, { type: :turn_canceled, cancellation_reason: reason,
|
|
1640
|
+
[nil, { type: :turn_canceled, cancellation_reason: reason, cancelled_by: cancelled_by(turn),
|
|
1641
|
+
duration_ms: (seconds * 1000).round }]
|
|
1641
1642
|
end
|
|
1642
1643
|
LLM::ModelResult.new(text: "", canceled: true, cancellation_reason: reason)
|
|
1643
1644
|
end
|
|
1644
1645
|
private :stopped_before_model
|
|
1645
1646
|
|
|
1647
|
+
# Who stopped a turn a hook stopped or cut (the controller's detail: the
|
|
1648
|
+
# bundle, or the hook's label), for the UIs' "stopped by loop-guard";
|
|
1649
|
+
# nil for any other cancel.
|
|
1650
|
+
def cancelled_by(turn)
|
|
1651
|
+
return nil unless turn.controller.reason == :hook
|
|
1652
|
+
|
|
1653
|
+
detail = turn.controller.detail
|
|
1654
|
+
by = detail[:by].to_s if detail.is_a?(Hash)
|
|
1655
|
+
by unless by.nil? || by.empty?
|
|
1656
|
+
end
|
|
1657
|
+
private :cancelled_by
|
|
1658
|
+
|
|
1646
1659
|
# The turn's settings for the kernel (LLM::TurnSettings): vision,
|
|
1647
1660
|
# sampling and thinking, the thinking resolved before the hooks run.
|
|
1648
1661
|
# The model name is added in #generate, after the hooks that may switch
|
|
@@ -1744,7 +1757,7 @@ module Samagotchi
|
|
|
1744
1757
|
end_turn(turn, canceled ? "canceled" : "completed", exhausted: !canceled && result.exhausted?) do |seconds|
|
|
1745
1758
|
kept = kept_messages(turn, result, seconds)
|
|
1746
1759
|
if canceled
|
|
1747
|
-
[kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason,
|
|
1760
|
+
[kept, { type: :turn_canceled, cancellation_reason: result.cancellation_reason, cancelled_by: cancelled_by(turn),
|
|
1748
1761
|
duration_ms: (seconds * 1000).round }]
|
|
1749
1762
|
else
|
|
1750
1763
|
# For a client that attaches later (session_state_snapshot).
|
data/lib/samagotchi/events.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Samagotchi
|
|
|
13
13
|
TURN_KEPT = %i[turn_completed turn_canceled].freeze
|
|
14
14
|
# The kinds of a running turn's parts (Bridge::TurnAccumulator), which a
|
|
15
15
|
# joining UI replays as live events.
|
|
16
|
-
PART_KINDS = %w[thinking text tool input steer reminder notice].freeze
|
|
16
|
+
PART_KINDS = %w[generation thinking text tool input steer reminder notice].freeze
|
|
17
17
|
# Types no Engine emits that a UI handles: the Bridge's stream frames
|
|
18
18
|
# and the web's one synthetic replay event (a prompt merged into the
|
|
19
19
|
# turn, turn_events.js snapshotEvents).
|
|
@@ -438,7 +438,7 @@ module Samagotchi
|
|
|
438
438
|
record_context_status(response.usage, window)
|
|
439
439
|
emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
|
|
440
440
|
thinking_chars: response.reasoning.to_s.length, served_model: response.model,
|
|
441
|
-
requested_model: @model_name, finish_reason: response.finish_reason)
|
|
441
|
+
served_provider: response.provider, requested_model: @model_name, finish_reason: response.finish_reason)
|
|
442
442
|
dump_response(response, iteration)
|
|
443
443
|
@loop.fire_hook(:after_generation, { type: :after_generation, iteration: iteration, response: response.text,
|
|
444
444
|
messages: AnswerDisplay.strip_all(@conversation).map(&:dup).freeze })
|
|
@@ -35,10 +35,12 @@ module Samagotchi
|
|
|
35
35
|
# ([] when none), usage (never nil) and the finish reason.
|
|
36
36
|
# +model+ is the model the server says answered (the body's `model`),
|
|
37
37
|
# which can differ from the one asked for; nil when it says nothing.
|
|
38
|
+
# +provider+ is the provider that served it (the body's `provider`,
|
|
39
|
+
# OpenRouter's routing name); nil when the server says nothing.
|
|
38
40
|
# +cut+ is set on the empty response the chat loop makes for a
|
|
39
41
|
# generation a plugin cut (stop_generation): {by:, reason:}.
|
|
40
|
-
ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :cut) do
|
|
41
|
-
def initialize(model: nil, cut: nil, **fields) = super
|
|
42
|
+
ChatResponse = Data.define(:text, :reasoning, :tool_calls, :usage, :finish_reason, :model, :provider, :cut) do
|
|
43
|
+
def initialize(model: nil, provider: nil, cut: nil, **fields) = super
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
# The OpenAI Chat Completions API (llama.cpp's /v1, and any compatible
|
|
@@ -248,7 +250,8 @@ module Samagotchi
|
|
|
248
250
|
ChatResponse.new(text: OpenAIChat.drop_leading_newlines(message["content"].to_s),
|
|
249
251
|
reasoning: (message["reasoning_content"] || message["reasoning"]).to_s,
|
|
250
252
|
tool_calls: calls, usage: Usage.from_payload(body) || Usage.none,
|
|
251
|
-
finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body)
|
|
253
|
+
finish_reason: body.dig("choices", 0, "finish_reason"), model: served_model(body),
|
|
254
|
+
provider: self.class.served_provider(body))
|
|
252
255
|
end
|
|
253
256
|
|
|
254
257
|
# Workaround for Splash 1.0.2/1.1.0, which keeps the "\n\n" the model
|
|
@@ -265,6 +268,15 @@ module Samagotchi
|
|
|
265
268
|
|
|
266
269
|
def served_model(payload) = self.class.served_model(payload)
|
|
267
270
|
|
|
271
|
+
# OpenRouter names the provider that served a request (Baseten,
|
|
272
|
+
# Fireworks, …) in the body's `provider`; other servers send none.
|
|
273
|
+
def self.served_provider(payload)
|
|
274
|
+
provider = payload.is_a?(Hash) ? payload["provider"] : nil
|
|
275
|
+
provider.is_a?(String) && !provider.strip.empty? ? provider : nil
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def served_provider(payload) = self.class.served_provider(payload)
|
|
279
|
+
|
|
268
280
|
# The parsed payload of a `data:` line; nil for blank lines, comments
|
|
269
281
|
# and [DONE]. Error events raise their ProviderError.
|
|
270
282
|
# A 200 whose body is a plain JSON error instead of a stream (OpenRouter
|
|
@@ -352,7 +364,7 @@ module Samagotchi
|
|
|
352
364
|
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
353
365
|
|
|
354
366
|
# Streamed deltas put together: text, reasoning, tool calls by index,
|
|
355
|
-
# the finish reason, the usage chunk
|
|
367
|
+
# the finish reason, the usage chunk, the served model and provider.
|
|
356
368
|
class Assembly
|
|
357
369
|
# The chunk carries part of a tool call.
|
|
358
370
|
def self.tool_call_delta?(payload)
|
|
@@ -367,12 +379,14 @@ module Samagotchi
|
|
|
367
379
|
@finish_reason = nil
|
|
368
380
|
@usage = nil
|
|
369
381
|
@model = nil
|
|
382
|
+
@provider = nil
|
|
370
383
|
end
|
|
371
384
|
|
|
372
385
|
# @return [Array(String, String)] this chunk's content and reasoning
|
|
373
386
|
def add(payload)
|
|
374
387
|
@usage = Usage.from_payload(payload) || @usage if payload.key?("usage")
|
|
375
388
|
@model = OpenAIChat.served_model(payload) || @model
|
|
389
|
+
@provider = OpenAIChat.served_provider(payload) || @provider
|
|
376
390
|
choice = payload["choices"].is_a?(Array) ? payload["choices"].first : nil
|
|
377
391
|
return ["", ""] unless choice.is_a?(Hash)
|
|
378
392
|
|
|
@@ -392,7 +406,7 @@ module Samagotchi
|
|
|
392
406
|
ToolCall.new(id: call[:id], name: call[:name].to_s, arguments: OpenAIChat.parse_arguments(call[:arguments]))
|
|
393
407
|
end
|
|
394
408
|
ChatResponse.new(text: @text, reasoning: @reasoning, tool_calls: calls, usage: @usage || Usage.none,
|
|
395
|
-
finish_reason: @finish_reason, model: @model)
|
|
409
|
+
finish_reason: @finish_reason, model: @model, provider: @provider)
|
|
396
410
|
end
|
|
397
411
|
|
|
398
412
|
private
|
|
@@ -82,7 +82,8 @@ module Samagotchi
|
|
|
82
82
|
end
|
|
83
83
|
|
|
84
84
|
def on_turn_canceled(event)
|
|
85
|
-
log(:info, :turn_canceled, ms: since(@turn_started_at), reason: event[:cancellation_reason],
|
|
85
|
+
log(:info, :turn_canceled, ms: since(@turn_started_at), reason: event[:cancellation_reason], by: event[:cancelled_by],
|
|
86
|
+
**origin(event))
|
|
86
87
|
end
|
|
87
88
|
|
|
88
89
|
def on_turn_failed(event)
|
|
@@ -100,7 +101,8 @@ module Samagotchi
|
|
|
100
101
|
def on_generation_completed(event)
|
|
101
102
|
ms = since(@generation_started_at.delete(event[:iteration]))
|
|
102
103
|
log(:info, :generation_completed, iteration: event[:iteration], ms: ms,
|
|
103
|
-
served_model: event[:served_model],
|
|
104
|
+
served_model: event[:served_model], provider: event[:served_provider],
|
|
105
|
+
requested_model: event[:requested_model],
|
|
104
106
|
content_length: event[:content_length],
|
|
105
107
|
thinking_chars: event[:thinking_chars], finish_reason: event[:finish_reason],
|
|
106
108
|
stopped_by: event[:stopped_by])
|
|
@@ -24,6 +24,7 @@ require_relative "installed_gem"
|
|
|
24
24
|
require_relative "installed_versions"
|
|
25
25
|
require_relative "recap_store"
|
|
26
26
|
require_relative "archive_store"
|
|
27
|
+
require_relative "continue_offer"
|
|
27
28
|
require_relative "image_store"
|
|
28
29
|
require_relative "plugin_session_state"
|
|
29
30
|
require_relative "terminal_ui"
|
|
@@ -105,6 +106,10 @@ module Samagotchi
|
|
|
105
106
|
case @reason
|
|
106
107
|
when :scratch then "a scratch session is deleted when you leave; nothing to archive"
|
|
107
108
|
when :busy_child then "its delegate #{@busy_id[0, 8]} is running a turn; wait for it or stop it first"
|
|
109
|
+
when :queued then "a prompt is queued, which a resume would run; let it run first"
|
|
110
|
+
when :queued_child then "its delegate #{@busy_id[0, 8]} has a prompt queued, which a resume would run; let it run first"
|
|
111
|
+
when :offer then "the step-limit question waits for an answer; answer Continue or Stop first"
|
|
112
|
+
when :offer_child then "its delegate #{@busy_id[0, 8]} waits for an answer to its step-limit question; answer it first"
|
|
108
113
|
else "a turn is running; wait for it or cancel it first"
|
|
109
114
|
end
|
|
110
115
|
end
|
|
@@ -309,8 +314,9 @@ module Samagotchi
|
|
|
309
314
|
# discarded: empty sessions their stopping worker deleted
|
|
310
315
|
# @raise [ArgumentError] unknown id (Session::AmbiguousId for a prefix of several)
|
|
311
316
|
# @raise [OwnedByTUI] a chi REPL owns it or one of its children
|
|
312
|
-
# @raise [ArchiveRefused] a turn runs in it or in a child,
|
|
313
|
-
#
|
|
317
|
+
# @raise [ArchiveRefused] a turn runs in it or in a child, a prompt is
|
|
318
|
+
# queued in one (a later resume would run it), a live one offers to
|
|
319
|
+
# continue past the step limit, or it is a scratch session
|
|
314
320
|
def self.archive_session(id_or_prefix, state_dir: nil, wait: 5)
|
|
315
321
|
sd = state_dir || Session.default_state_dir
|
|
316
322
|
id = archive_target(id_or_prefix, sd)
|
|
@@ -319,13 +325,8 @@ module Samagotchi
|
|
|
319
325
|
tree = [id, *descendant_ids(id, sd)]
|
|
320
326
|
owners = tree.to_h { |sid| [sid, session_owner(sid, state_dir: sd)] }
|
|
321
327
|
tree.each do |sid|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
raise OwnedByTUI, sid if owner.tui?
|
|
326
|
-
next unless Session.load(sid, state_dir: sd).status == Session::STATUS_RUNNING
|
|
327
|
-
|
|
328
|
-
raise ArchiveRefused.new(id, sid == id ? :busy : :busy_child, busy_id: sid)
|
|
328
|
+
refusal = archive_refusal(sid, owners[sid], sd)
|
|
329
|
+
raise ArchiveRefused.new(id, sid == id ? refusal : :"#{refusal}_child", busy_id: sid) if refusal
|
|
329
330
|
end
|
|
330
331
|
|
|
331
332
|
stopped = tree.select { |sid| owners[sid] }
|
|
@@ -334,6 +335,22 @@ module Samagotchi
|
|
|
334
335
|
{ id: id, archived: archived, stopped: stopped, discarded: discarded }
|
|
335
336
|
end
|
|
336
337
|
|
|
338
|
+
# What keeps +sid+ (in an archive's tree) from being archived: :busy (a
|
|
339
|
+
# turn runs), :queued (input/ holds a prompt, which outlives the stop and
|
|
340
|
+
# runs on a resume, live worker or not), :offer (a live worker's continue
|
|
341
|
+
# offer, which the stop would drop), or nil.
|
|
342
|
+
# @raise [OwnedByTUI] a chi REPL owns it
|
|
343
|
+
def self.archive_refusal(sid, owner, state_dir)
|
|
344
|
+
raise OwnedByTUI, sid if owner&.tui?
|
|
345
|
+
|
|
346
|
+
session = owner && Session.load(sid, state_dir: state_dir)
|
|
347
|
+
return :busy if session&.status == Session::STATUS_RUNNING
|
|
348
|
+
return :queued unless SessionInbox.find_new_input_files(Session.session_dir(sid, state_dir: state_dir)).empty?
|
|
349
|
+
|
|
350
|
+
:offer if session&.waiting_question(live: true)&.dig(:kind) == ContinueOffer::KIND
|
|
351
|
+
end
|
|
352
|
+
private_class_method :archive_refusal
|
|
353
|
+
|
|
337
354
|
# Unarchive a session and its delegated children.
|
|
338
355
|
# @return [Hash] {id:, unarchived: [ids that were archived]}
|
|
339
356
|
# @raise [ArgumentError] unknown id
|
|
@@ -277,7 +277,7 @@ module Samagotchi
|
|
|
277
277
|
when :turn_completed
|
|
278
278
|
end_turn(status: "completed")
|
|
279
279
|
when :turn_canceled
|
|
280
|
-
end_turn(status: "canceled", reason: event[:cancellation_reason])
|
|
280
|
+
end_turn(status: "canceled", reason: event[:cancellation_reason], by: event[:cancelled_by])
|
|
281
281
|
when :turn_failed
|
|
282
282
|
end_turn(status: "failed")
|
|
283
283
|
end
|
|
@@ -457,9 +457,10 @@ module Samagotchi
|
|
|
457
457
|
end
|
|
458
458
|
end
|
|
459
459
|
|
|
460
|
-
# A canceled turn's record keeps why (+reason+: "user", "ctrl_c", ...)
|
|
461
|
-
#
|
|
462
|
-
|
|
460
|
+
# A canceled turn's record keeps why (+reason+: "user", "ctrl_c", ...)
|
|
461
|
+
# and who stopped it (+by+: a hook's bundle), so a reloaded web history
|
|
462
|
+
# can show the cancel line the live one did.
|
|
463
|
+
def end_turn(status: "completed", reason: nil, by: nil)
|
|
463
464
|
@mutex.synchronize do
|
|
464
465
|
if @turn
|
|
465
466
|
# A turn that failed mid-stream never saw its generation end:
|
|
@@ -478,6 +479,7 @@ module Samagotchi
|
|
|
478
479
|
duration_ms: elapsed_ms(@turn.started_monotonic)
|
|
479
480
|
}
|
|
480
481
|
record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
|
|
482
|
+
record[:cancelled_by] = by.to_s unless by.nil? || by.to_s.empty?
|
|
481
483
|
record.merge!(turn_token_fields(@turn))
|
|
482
484
|
@turn_records << record
|
|
483
485
|
count_turn(record)
|
data/lib/samagotchi/steer.rb
CHANGED
|
@@ -80,7 +80,7 @@ module Samagotchi
|
|
|
80
80
|
# :pending_input_merged. After a cancel the input stays queued: it runs
|
|
81
81
|
# as the next turn instead of dying with this one. +answer+ (a String,
|
|
82
82
|
# or a Proc called only on a merge) is the answer the merge follows, for
|
|
83
|
-
# the
|
|
83
|
+
# the TUI's line (the web has it as the step's streamed text); given, the drain is told it is the after-answer site (plugin
|
|
84
84
|
# steers are dropped there). A blank answer is none.
|
|
85
85
|
# @return [Boolean] whether anything was injected
|
|
86
86
|
def inject!(conversation, pending_input, iteration:, emit:, cancel_controller:, answer: nil)
|
|
@@ -47,6 +47,10 @@ module Samagotchi
|
|
|
47
47
|
DETACH_HINT = "Ctrl-D to detach, /exit stops the worker"
|
|
48
48
|
# Why a request the Bridge read after its deadline didn't go through.
|
|
49
49
|
LATE = "worker not answering in time"
|
|
50
|
+
# An exit the worker agreed to in an empty session. Said before it has
|
|
51
|
+
# left: it checks again once it lets go, and a note or prompt arriving
|
|
52
|
+
# first keeps the session (SessionManager.discard_left_session).
|
|
53
|
+
EMPTY_EXIT = "Detached; the session is empty: it will be discarded if nothing arrives before its worker stops."
|
|
50
54
|
# Why the worker stayed up after /exit, by the reason it gave.
|
|
51
55
|
HELD_REASONS = {
|
|
52
56
|
"turn_running" => "a turn is running", "input_queued" => "prompts are queued",
|
|
@@ -420,7 +424,9 @@ module Samagotchi
|
|
|
420
424
|
return detach("#{line} Not deleted.")
|
|
421
425
|
end
|
|
422
426
|
# An empty session: the worker deletes it as it leaves.
|
|
423
|
-
|
|
427
|
+
return detach(EMPTY_EXIT) if discards?(reply)
|
|
428
|
+
|
|
429
|
+
@delete_session.call(id)
|
|
424
430
|
detach("Detached; deleted session #{id}.")
|
|
425
431
|
rescue SystemCallError, IOError => e
|
|
426
432
|
detach("#{exit_failed_line(e.message)} Not deleted.")
|
|
@@ -429,14 +435,15 @@ module Samagotchi
|
|
|
429
435
|
end
|
|
430
436
|
|
|
431
437
|
# /archive: the worker is asked to exit as for /exit, then the session
|
|
432
|
-
# is archived. A worker staying up for another UI (or reminders
|
|
433
|
-
#
|
|
434
|
-
#
|
|
438
|
+
# is archived. A worker staying up for another UI (or reminders) is
|
|
439
|
+
# stopped by the archive, as the web's archive stops it; a turn
|
|
440
|
+
# running, a queued prompt or an open continue offer refuses it
|
|
441
|
+
# (SessionManager.archive_session). An empty session: the worker
|
|
435
442
|
# deletes it as it leaves.
|
|
436
443
|
def exit_and_archive
|
|
437
444
|
reply = @client.request_exit(client_id: @client_id)
|
|
438
445
|
id = @client.session_id
|
|
439
|
-
return detach(
|
|
446
|
+
return detach(EMPTY_EXIT) if reply.status == 200 && discards?(reply)
|
|
440
447
|
return detach("#{exit_line(reply)} Not archived.") unless [200, 409].include?(reply.status)
|
|
441
448
|
|
|
442
449
|
result = @archive_session.call(id)
|
|
@@ -454,15 +461,14 @@ module Samagotchi
|
|
|
454
461
|
error = reply.json&.fetch("error", nil)
|
|
455
462
|
case reply.status
|
|
456
463
|
when 200
|
|
457
|
-
|
|
458
|
-
# does keeps the session after all (rare, left as is).
|
|
459
|
-
return "Detached; the session was empty, so it is discarded." if discards?(reply)
|
|
464
|
+
return EMPTY_EXIT if discards?(reply)
|
|
460
465
|
|
|
461
466
|
"Detached; the session's worker is stopping. Resume with: chi --resume #{id}"
|
|
462
467
|
when 409
|
|
463
468
|
reason = reply.json&.fetch("reason", nil).to_s
|
|
464
469
|
"Detached; the session keeps running (#{HELD_REASONS.fetch(reason, reason)}). Re-attach with: chi --attach #{id}"
|
|
465
470
|
when 0 then exit_failed_line("no reply")
|
|
471
|
+
when 408 then exit_failed_line(LATE)
|
|
466
472
|
else
|
|
467
473
|
# A worker from before the route answers the Bridge's plain 404.
|
|
468
474
|
return "Detached; this worker runs an older chi and can't be stopped from here: chi sessions stop #{id}" if error == "not_found"
|
|
@@ -1029,7 +1035,8 @@ module Samagotchi
|
|
|
1029
1035
|
exchange = messages.select { |m| %w[user model assistant].include?(m[:role].to_s) }
|
|
1030
1036
|
# The id stays findable: the detach line and chi sessions list show it.
|
|
1031
1037
|
Log.session_id = @client.session_id
|
|
1032
|
-
Log.info(:attached, "joined", session: @client.session_id, messages: exchange.size
|
|
1038
|
+
Log.info(:attached, "joined", session: @client.session_id, messages: exchange.size,
|
|
1039
|
+
notes: messages.count { |m| ContextNote.note?(m) })
|
|
1033
1040
|
last_user = exchange.rindex { |m| Steer.prompt?(m) }
|
|
1034
1041
|
render_join_notes(messages)
|
|
1035
1042
|
return unless last_user
|
|
@@ -1166,6 +1173,8 @@ module Samagotchi
|
|
|
1166
1173
|
running_tool = nil
|
|
1167
1174
|
Bridge::TurnAccumulator.replay_events(turn).each do |event|
|
|
1168
1175
|
case event[:type]
|
|
1176
|
+
# A new step starts its line over, as live (a join during a hold).
|
|
1177
|
+
when :generation_started then tail, lane = nil, :writing
|
|
1169
1178
|
when :generation_chunk
|
|
1170
1179
|
tail, lane = event[:thinking].to_s.empty? ? [event[:text], :writing] : [event[:thinking], :thinking]
|
|
1171
1180
|
when :tool_call_started
|
|
@@ -84,7 +84,7 @@ module Samagotchi
|
|
|
84
84
|
render_turn_summary(event[:turn_summary]) if event[:turn_summary]
|
|
85
85
|
when :turn_canceled
|
|
86
86
|
@view.finish_thinking_spinner
|
|
87
|
-
@view.print_line(@view.turn_canceled_line(event[:cancellation_reason], event[:duration_ms]))
|
|
87
|
+
@view.print_line(@view.turn_canceled_line(event[:cancellation_reason], event[:duration_ms], by: event[:cancelled_by]))
|
|
88
88
|
@view.print_line(@view.turn_end_hint(Formatting::ROLLBACK_HINT)) unless @turn_continues
|
|
89
89
|
when :turn_failed
|
|
90
90
|
# A provider error's one-line summary, else the message (an image
|
|
@@ -203,8 +203,13 @@ module Samagotchi
|
|
|
203
203
|
# started): its partial progress stays in the conversation.
|
|
204
204
|
ROLLBACK_HINT = "partial progress kept; !rollback restores the pre-turn state"
|
|
205
205
|
|
|
206
|
-
# "✕ turn canceled (Ctrl-C) · 3.1s"
|
|
207
|
-
|
|
206
|
+
# "✕ turn canceled (Ctrl-C) · 3.1s"; a hook's stop that names who
|
|
207
|
+
# (+by+, turn_canceled's cancelled_by): "✕ turn stopped by loop-guard"
|
|
208
|
+
def turn_canceled_line(reason, duration_ms, by: nil)
|
|
209
|
+
if reason.to_s == "hook" && !by.to_s.empty?
|
|
210
|
+
return "#{paint("✕", 33)} turn stopped by #{by}#{turn_duration_suffix(duration_ms)}"
|
|
211
|
+
end
|
|
212
|
+
|
|
208
213
|
label = reason.to_s.empty? ? nil : CANCEL_REASONS.fetch(reason.to_s, reason.to_s)
|
|
209
214
|
"#{paint("✕", 33)} turn canceled#{" (#{label})" if label}#{turn_duration_suffix(duration_ms)}"
|
|
210
215
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "io/console"
|
|
3
4
|
require_relative "screen"
|
|
4
5
|
require_relative "reline_seam"
|
|
5
6
|
|
|
@@ -22,10 +23,48 @@ module Samagotchi
|
|
|
22
23
|
screen
|
|
23
24
|
end
|
|
24
25
|
|
|
25
|
-
#
|
|
26
|
-
|
|
26
|
+
# How long #drain_input waits for input still on its way.
|
|
27
|
+
DRAIN_WINDOW = 0.05
|
|
28
|
+
# How long Reline waits for the reply to its cursor query (Reline::ANSI#cursor_pos).
|
|
29
|
+
CURSOR_REPLY_WAIT = 0.5
|
|
30
|
+
CURSOR_REPLY = /\e\[\d+;\d+R/
|
|
31
|
+
|
|
32
|
+
# Hand Reline its own drawing back, close the surface, then drop the
|
|
33
|
+
# input nobody read (#drain_input).
|
|
34
|
+
def close(surface, input: $stdin)
|
|
27
35
|
RelineSeam.detach(surface)
|
|
28
36
|
surface.close
|
|
37
|
+
query_at = RelineSeam.unanswered_query_at
|
|
38
|
+
RelineSeam.unanswered_query_at = nil
|
|
39
|
+
drain_input(input, query_at: query_at) if surface.is_a?(Screen)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Reline asks the terminal for the cursor row (ESC[6n) as each read
|
|
43
|
+
# starts; the reply to one cut short at exit, read by nobody, would
|
|
44
|
+
# land at the shell's prompt. What came, or comes within DRAIN_WINDOW,
|
|
45
|
+
# is read and dropped (keys typed in that moment too), in raw mode, so
|
|
46
|
+
# a reply with no newline is readable. With a query left unanswered
|
|
47
|
+
# (RelineSeam.unanswered_query_at), until its reply, up to as long as
|
|
48
|
+
# Reline would have waited for it.
|
|
49
|
+
def drain_input(input, query_at: nil)
|
|
50
|
+
return unless input.is_a?(IO) && input.tty?
|
|
51
|
+
|
|
52
|
+
now = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
53
|
+
deadline = now.call + DRAIN_WINDOW
|
|
54
|
+
deadline = [deadline, query_at + CURSOR_REPLY_WAIT].max if query_at
|
|
55
|
+
dropped = +""
|
|
56
|
+
input.raw do
|
|
57
|
+
while (left = deadline - now.call).positive? && input.wait_readable(left)
|
|
58
|
+
read = input.read_nonblock(1024, exception: false)
|
|
59
|
+
break if read.nil?
|
|
60
|
+
|
|
61
|
+
dropped << read if read.is_a?(String)
|
|
62
|
+
# The reply came: no need to wait out the rest.
|
|
63
|
+
deadline = [now.call + DRAIN_WINDOW, deadline].min if query_at && dropped.match?(CURSOR_REPLY)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
rescue IOError, SystemCallError
|
|
67
|
+
nil
|
|
29
68
|
end
|
|
30
69
|
|
|
31
70
|
def available?(out:, input:, env:)
|
|
@@ -66,6 +66,12 @@ module Samagotchi
|
|
|
66
66
|
# @api private
|
|
67
67
|
attr_writer :reading
|
|
68
68
|
|
|
69
|
+
# Monotonic time a read's cursor query went out (#reset) when the read
|
|
70
|
+
# was stopped before Reline took the reply: the reply is still on its
|
|
71
|
+
# way, and LiveRegion.close waits for it. nil with none.
|
|
72
|
+
# @return [Float, nil]
|
|
73
|
+
attr_accessor :unanswered_query_at
|
|
74
|
+
|
|
69
75
|
# @return [Boolean] this Reline has every method the seam relies on
|
|
70
76
|
def supported?
|
|
71
77
|
defined?(Reline::LineEditor::RenderedScreen) &&
|
|
@@ -107,7 +113,11 @@ module Samagotchi
|
|
|
107
113
|
end
|
|
108
114
|
|
|
109
115
|
def reset(...)
|
|
116
|
+
# Reline's reset asks the terminal for the cursor row and waits for
|
|
117
|
+
# the reply: a Stop raised meanwhile leaves this set.
|
|
118
|
+
RelineSeam.unanswered_query_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
110
119
|
super
|
|
120
|
+
RelineSeam.unanswered_query_at = nil
|
|
111
121
|
RelineSeam.reading = true
|
|
112
122
|
@rendered_screen.base_y = 0 if RelineSeam.screen
|
|
113
123
|
end
|
data/lib/samagotchi/version.rb
CHANGED
|
@@ -45,7 +45,7 @@ import {
|
|
|
45
45
|
appendAboveLiveTiming, cancelLineText, elapsedSince, formatDuration, mergeTiming, normalizeTiming, turnTimingText,
|
|
46
46
|
} from "./timing.js";
|
|
47
47
|
import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
|
|
48
|
-
import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, retryStatusLine, snapshotEvents, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
48
|
+
import { clientLabel, commandView, displayWait, dropEarlyRestores, emptyAnswerLine, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
|
|
49
49
|
|
|
50
50
|
const $ = (s) => document.querySelector(s);
|
|
51
51
|
const topStripEl = $("#topStrip");
|
|
@@ -134,6 +134,9 @@ const questionCards = createQuestionCards({
|
|
|
134
134
|
// was rendered at (null: no live worker then), where a stream opened later
|
|
135
135
|
// has to start.
|
|
136
136
|
let selectSeq = null;
|
|
137
|
+
// The stream was opened from 0 (a wake with no cursor to go on) and no
|
|
138
|
+
// new turn has started on it yet: see followWokenWorker.
|
|
139
|
+
let streamFromGuess = false;
|
|
137
140
|
// The last input_merged had an origin with no bubble: its merged text
|
|
138
141
|
// (pending_input_merged) gets one.
|
|
139
142
|
let unmatchedMerge = false;
|
|
@@ -165,6 +168,7 @@ let scopeError = null;
|
|
|
165
168
|
async function refresh() {
|
|
166
169
|
try {
|
|
167
170
|
allSessions = await listSessions("updated_at", "desc", { dir: scope.dir });
|
|
171
|
+
notifications.noticeList(allSessions);
|
|
168
172
|
} catch (e) {
|
|
169
173
|
if (e.status !== 400 || !scope.dir) throw e;
|
|
170
174
|
scopeError = e.message;
|
|
@@ -1341,7 +1345,7 @@ function onCardAction(e) {
|
|
|
1341
1345
|
sendCommand(selected, button.dataset.command, { clientId, card: true })
|
|
1342
1346
|
.then(() => {
|
|
1343
1347
|
// The command woke the worker: follow its stream if none is open.
|
|
1344
|
-
|
|
1348
|
+
followWokenWorker();
|
|
1345
1349
|
})
|
|
1346
1350
|
.catch((error) => {
|
|
1347
1351
|
err.textContent = error.message;
|
|
@@ -1416,6 +1420,7 @@ function updateInfoBar() {
|
|
|
1416
1420
|
archiveBtn.style.display = selected ? "" : "none";
|
|
1417
1421
|
const archived = selectedArchived();
|
|
1418
1422
|
archiveBtn.textContent = archived ? "unarchive" : "archive";
|
|
1423
|
+
archiveBtn.classList.toggle("archived", archived);
|
|
1419
1424
|
archiveBtn.title = archived ? "Unarchive: back to the lists" : "Archive: hide from the lists, keep for good";
|
|
1420
1425
|
}
|
|
1421
1426
|
if (!selected) {
|
|
@@ -1593,7 +1598,18 @@ function setUsedMemories(names) {
|
|
|
1593
1598
|
}
|
|
1594
1599
|
}
|
|
1595
1600
|
|
|
1601
|
+
// A turn or command just woke the session's worker: follow its stream if
|
|
1602
|
+
// none is open, from the render's cursor. A render with none (no worker
|
|
1603
|
+
// then) streams from 0, the start of a new worker's events; should that
|
|
1604
|
+
// worker have been up already, turn_started says so (replaysDrawnTurn).
|
|
1605
|
+
function followWokenWorker() {
|
|
1606
|
+
if (streamControl?.live) return;
|
|
1607
|
+
startStream(selected, selectSeq ?? 0);
|
|
1608
|
+
streamFromGuess = selectSeq === null;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1596
1611
|
function startStream(id, fromSeq) {
|
|
1612
|
+
streamFromGuess = false;
|
|
1597
1613
|
if (streamControl) {
|
|
1598
1614
|
streamControl.close();
|
|
1599
1615
|
streamControl = null;
|
|
@@ -1675,7 +1691,8 @@ const streamHandlers = {
|
|
|
1675
1691
|
// Prompts sent while a turn ran merge into it at an iteration boundary:
|
|
1676
1692
|
// input_merged names them (their bubbles turn "steered"), then
|
|
1677
1693
|
// pending_input_merged carries the merged text, shown only for prompts
|
|
1678
|
-
// this tab has no bubble for.
|
|
1694
|
+
// this tab has no bubble for. Its `answer` (the answer the merge
|
|
1695
|
+
// superseded) is the TUI's line: here it streamed as that step's text.
|
|
1679
1696
|
input_merged: (data) => applyPromptOps(data),
|
|
1680
1697
|
pending_input_merged: (data) => {
|
|
1681
1698
|
applyPromptOps(data);
|
|
@@ -1683,6 +1700,16 @@ const streamHandlers = {
|
|
|
1683
1700
|
},
|
|
1684
1701
|
turn_enqueued: (data) => applyPromptOps(data),
|
|
1685
1702
|
turn_started: (data) => keepFollowing(historyEl, () => {
|
|
1703
|
+
if (streamFromGuess) {
|
|
1704
|
+
streamFromGuess = false;
|
|
1705
|
+
// A replay of a turn the render drew: re-read and stream from there.
|
|
1706
|
+
if (replaysDrawnTurn(data, current.timing.turnRecords)) {
|
|
1707
|
+
streamControl?.close();
|
|
1708
|
+
streamControl = null;
|
|
1709
|
+
resync();
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1686
1713
|
// A finished turn still in the stage goes first: the start op looks
|
|
1687
1714
|
// for a queued prompt among its rows.
|
|
1688
1715
|
turnDom.handOff?.({ immediate: true });
|
|
@@ -1729,7 +1756,7 @@ const streamHandlers = {
|
|
|
1729
1756
|
}
|
|
1730
1757
|
},
|
|
1731
1758
|
turn_canceled: (data) => {
|
|
1732
|
-
endTurnView("canceled", cancelLineText(data.cancellation_reason || ""));
|
|
1759
|
+
endTurnView("canceled", cancelLineText(data.cancellation_reason || "", data.cancelled_by));
|
|
1733
1760
|
setLiveStatus("idle");
|
|
1734
1761
|
refreshTimingFromSession();
|
|
1735
1762
|
},
|
|
@@ -1965,7 +1992,7 @@ async function handleSendTurn() {
|
|
|
1965
1992
|
echo.remove();
|
|
1966
1993
|
promptEl.value = "";
|
|
1967
1994
|
fitPrompt();
|
|
1968
|
-
|
|
1995
|
+
followWokenWorker();
|
|
1969
1996
|
return;
|
|
1970
1997
|
}
|
|
1971
1998
|
if (ack?.enqueued_id) sentIds.add(ack.enqueued_id);
|
|
@@ -1987,7 +2014,7 @@ async function handleSendTurn() {
|
|
|
1987
2014
|
// events in between. Without one, POST /turn just woke a worker: its
|
|
1988
2015
|
// events start after the seq the session was rendered at (0 for a new
|
|
1989
2016
|
// worker process).
|
|
1990
|
-
|
|
2017
|
+
followWokenWorker();
|
|
1991
2018
|
} catch (e) {
|
|
1992
2019
|
echo.remove();
|
|
1993
2020
|
// The turn never reached the worker's queue: an early restore waiting
|
|
@@ -2028,7 +2055,7 @@ async function handleSendCommand(line) {
|
|
|
2028
2055
|
fitPrompt();
|
|
2029
2056
|
refreshHistory();
|
|
2030
2057
|
// The command woke the worker: follow its stream if none is open.
|
|
2031
|
-
|
|
2058
|
+
followWokenWorker();
|
|
2032
2059
|
} catch (e) {
|
|
2033
2060
|
if (/owned_by_tui|409/.test(e.message)) {
|
|
2034
2061
|
current.owner = "tui";
|
|
@@ -606,6 +606,13 @@ body.stage-view #dock,body.stage-view #dock:focus-within{background:transparent;
|
|
|
606
606
|
#infoBar .meta .mem-chip{display:inline;order:-1}
|
|
607
607
|
#infoBar .meta .dir{word-break:normal}
|
|
608
608
|
#infoBar button{padding:4px 8px}
|
|
609
|
+
/* Icon-only buttons (the words stay as the accessible name, the title says more): the meta gets the width. */
|
|
610
|
+
#infoBar>button{font-size:0;line-height:0;padding:5px 7px}
|
|
611
|
+
#infoBar>button::before{content:"";display:block;width:14px;height:14px;background:currentColor;-webkit-mask:var(--icon) center/contain no-repeat;mask:var(--icon) center/contain no-repeat}
|
|
612
|
+
#infoBar #infoArchiveBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M6.5 9h3'/%3E%3C/svg%3E")}
|
|
613
|
+
#infoBar #infoArchiveBtn.archived{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='2.75' width='12' height='3.5' rx='1'/%3E%3Cpath d='M3.25 6.25v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1v-6M8 11.5V8.25M6.5 9.75 8 8.25l1.5 1.5'/%3E%3C/svg%3E")}
|
|
614
|
+
#infoBar #infoStopBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 2v5.5M4.6 4.4a5 5 0 1 0 6.8 0'/%3E%3C/svg%3E")}
|
|
615
|
+
#infoBar #infoDeleteBtn{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2.5 4.5h11M6 4.5V2.75h4V4.5M4 4.5l.7 8.6a1 1 0 0 0 1 .9h4.6a1 1 0 0 0 1-.9l.7-8.6M6.75 7.25v4.5M9.25 7.25v4.5'/%3E%3C/svg%3E")}
|
|
609
616
|
}
|
|
610
617
|
#empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--text-muted);padding:24px;text-align:center;background:color-mix(in srgb,var(--bg) 35%,transparent);z-index:1}
|
|
611
618
|
#empty.hidden{display:none !important}
|
|
@@ -35,7 +35,7 @@ function defaultChannel() {
|
|
|
35
35
|
// @param storage () => localStorage (it can throw)
|
|
36
36
|
// @param channel the BroadcastChannel to the other chi tabs, or null
|
|
37
37
|
// @param gateOptions passed to createNotifyGate (specs: schedule, now)
|
|
38
|
-
// @return {noticeSessions(type, data), setBaseTitle(title), clearBadge()}
|
|
38
|
+
// @return {noticeSessions(type, data), noticeList(sessions), setBaseTitle(title), clearBadge()}
|
|
39
39
|
export function createNotifications({
|
|
40
40
|
button,
|
|
41
41
|
doc = globalThis.document,
|
|
@@ -125,11 +125,12 @@ export function createNotifications({
|
|
|
125
125
|
notifyGate.seenInFront(next.attentions.map((a) => a.key));
|
|
126
126
|
} else {
|
|
127
127
|
for (const a of next.attentions) {
|
|
128
|
-
|
|
128
|
+
// Every tab behind badges; one of them notifies (notify.js).
|
|
129
|
+
notifyGate.offer(a, (notify) => {
|
|
129
130
|
if (tabInFront()) return;
|
|
130
131
|
badgeKeys.add(a.key);
|
|
131
132
|
setTitle();
|
|
132
|
-
showNotification(attention.byId[a.sessionId], a);
|
|
133
|
+
if (notify) showNotification(attention.byId[a.sessionId], a);
|
|
133
134
|
});
|
|
134
135
|
}
|
|
135
136
|
}
|
|
@@ -159,6 +160,10 @@ export function createNotifications({
|
|
|
159
160
|
|
|
160
161
|
return {
|
|
161
162
|
noticeSessions,
|
|
163
|
+
// The list fetched without a hub (app.js refresh): a snapshot.
|
|
164
|
+
noticeList(sessions) {
|
|
165
|
+
noticeSessions("snapshot", { sessions });
|
|
166
|
+
},
|
|
162
167
|
// The title without the badge ("Chi · <project>").
|
|
163
168
|
setBaseTitle(title) {
|
|
164
169
|
baseTitle = title;
|