samagotchi 0.20.0 → 0.22.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 +77 -1
- data/docs/architecture.md +5 -3
- data/docs/cli.md +2 -1
- data/docs/configuration.md +11 -0
- data/docs/plugins.md +22 -8
- data/docs/sessions.md +3 -3
- data/docs/sub-agent.md +11 -5
- data/lib/samagotchi/answer_command.rb +7 -3
- 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/cli/parent_wait.rb +9 -2
- data/lib/samagotchi/continue_offer.rb +34 -9
- data/lib/samagotchi/engine.rb +47 -4
- data/lib/samagotchi/events.rb +1 -1
- data/lib/samagotchi/llm/chat_loop.rb +1 -1
- data/lib/samagotchi/llm/errors.rb +31 -0
- data/lib/samagotchi/llm/openai_chat.rb +33 -7
- data/lib/samagotchi/llm/prompt_cache.rb +83 -0
- data/lib/samagotchi/log_subscriber.rb +4 -2
- data/lib/samagotchi/parent_report.rb +24 -7
- data/lib/samagotchi/question_desk.rb +1 -7
- data/lib/samagotchi/reply_wait.rb +12 -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/turn_state.rb +22 -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 +2 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Samagotchi
|
|
4
|
+
module LLM
|
|
5
|
+
# Prompt-cache breakpoints for Claude models behind an OpenAI-compatible
|
|
6
|
+
# host (OpenRouter, a gateway). Anthropic caches a prompt only up to a
|
|
7
|
+
# content part marked with cache_control, in the order tools → system →
|
|
8
|
+
# messages, so two marks cover a chat request:
|
|
9
|
+
# - the first system message (with it, the tool list), and
|
|
10
|
+
# - the last non-system message with content, so the next request (the
|
|
11
|
+
# same prefix plus new messages) reads everything up to it.
|
|
12
|
+
# Mid and tail system messages (reminders, turn notes) never take the
|
|
13
|
+
# second mark. Other models' messages pass through untouched.
|
|
14
|
+
module PromptCache
|
|
15
|
+
CONTROL = { type: "ephemeral" }.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# Whether +model+ (the bare id a host is asked for) names a Claude model.
|
|
20
|
+
def claude?(model) = model.to_s.match?(/claude/i)
|
|
21
|
+
|
|
22
|
+
# +messages+ (wire messages: symbol keys, content a String or an Array
|
|
23
|
+
# of parts) with the breakpoints set when +model+ is a Claude model;
|
|
24
|
+
# the given messages as they are otherwise. The marked messages and
|
|
25
|
+
# parts are copies: the caller's are never changed.
|
|
26
|
+
# @return [Array<Hash>]
|
|
27
|
+
def mark(messages, model:)
|
|
28
|
+
return messages unless claude?(model)
|
|
29
|
+
|
|
30
|
+
indexes = [system_index(messages), last_index(messages)].compact.uniq
|
|
31
|
+
return messages if indexes.empty?
|
|
32
|
+
|
|
33
|
+
messages.each_with_index.map { |message, index| indexes.include?(index) ? marked(message) : message }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Whether any message in +messages+ carries a breakpoint.
|
|
37
|
+
def marked?(messages)
|
|
38
|
+
Array(messages).any? do |message|
|
|
39
|
+
content = message[:content]
|
|
40
|
+
content.is_a?(Array) && content.any? { |part| part.is_a?(Hash) && control?(part) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def system_index(messages)
|
|
45
|
+
index = messages.index { |message| message[:role].to_s == "system" }
|
|
46
|
+
index if index && content?(messages[index][:content])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The last message that isn't a system message and has content (an
|
|
50
|
+
# assistant tool_calls message has nil or "").
|
|
51
|
+
def last_index(messages)
|
|
52
|
+
messages.rindex { |message| message[:role].to_s != "system" && content?(message[:content]) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def content?(content)
|
|
56
|
+
case content
|
|
57
|
+
when String then !content.empty?
|
|
58
|
+
when Array then content.any? { |part| part.is_a?(Hash) }
|
|
59
|
+
else false
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A String becomes one text part; an Array gets the mark on its last
|
|
64
|
+
# text part (the last part when none is text).
|
|
65
|
+
def marked(message)
|
|
66
|
+
content = message[:content]
|
|
67
|
+
return message.merge(content: [{ type: "text", text: content, cache_control: CONTROL }]) if content.is_a?(String)
|
|
68
|
+
|
|
69
|
+
target = content.rindex { |part| part.is_a?(Hash) && (part[:type] || part["type"]).to_s == "text" } ||
|
|
70
|
+
content.rindex { |part| part.is_a?(Hash) }
|
|
71
|
+
part = content[target]
|
|
72
|
+
key = part.key?("type") ? "cache_control" : :cache_control
|
|
73
|
+
parts = content.dup
|
|
74
|
+
parts[target] = part.merge(key => CONTROL)
|
|
75
|
+
message.merge(content: parts)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def control?(part) = part.key?(:cache_control) || part.key?("cache_control")
|
|
79
|
+
|
|
80
|
+
private_class_method :system_index, :last_index, :content?, :marked, :control?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -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])
|
|
@@ -70,8 +70,10 @@ module Samagotchi
|
|
|
70
70
|
|
|
71
71
|
# @param session_id [String]
|
|
72
72
|
# @param timeout [Numeric, nil] the wait's --timeout (for the running detail)
|
|
73
|
+
# @param extra [Hash] more keys for the object (chi answer's
|
|
74
|
+
# answered_here: false)
|
|
73
75
|
# @return [Hash] the --format json object
|
|
74
|
-
def json(result, session_id:, timeout: nil)
|
|
76
|
+
def json(result, session_id:, timeout: nil, extra: {})
|
|
75
77
|
report = { status: status(result), session_id: session_id }
|
|
76
78
|
case report[:status]
|
|
77
79
|
when "answered" then report[:text] = result.text.to_s
|
|
@@ -79,24 +81,39 @@ module Samagotchi
|
|
|
79
81
|
question = question(result.question)
|
|
80
82
|
report[:question] = question
|
|
81
83
|
report[:answer_with] = answer_with(session_id, question)
|
|
82
|
-
else
|
|
84
|
+
else
|
|
85
|
+
report.merge!(stop_facts(result))
|
|
86
|
+
report[:detail] = detail(result, session_id: session_id, timeout: timeout)
|
|
87
|
+
end
|
|
88
|
+
report.merge(extra)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Why a failed or canceled turn stopped, as far as last_turn knows
|
|
92
|
+
# (ReplyWait::Result): error_kind and retryable, cancel_reason and
|
|
93
|
+
# stopped_by; absent when unknown.
|
|
94
|
+
# @return [Hash]
|
|
95
|
+
def stop_facts(result)
|
|
96
|
+
case status(result)
|
|
97
|
+
when "failed" then { error_kind: result.error_kind, retryable: result.retryable }.compact
|
|
98
|
+
when "canceled" then { cancel_reason: result.cancel_reason, stopped_by: result.stopped_by }.compact
|
|
99
|
+
else {}
|
|
83
100
|
end
|
|
84
|
-
report
|
|
85
101
|
end
|
|
86
102
|
|
|
87
103
|
# @return [String] the JSON object as one line
|
|
88
|
-
def json_line(result, session_id:, timeout: nil)
|
|
89
|
-
JSON.generate(json(result, session_id: session_id, timeout: timeout))
|
|
104
|
+
def json_line(result, session_id:, timeout: nil, extra: {})
|
|
105
|
+
JSON.generate(json(result, session_id: session_id, timeout: timeout, extra: extra))
|
|
90
106
|
end
|
|
91
107
|
|
|
92
108
|
# Print a wait's end the way the commands do: the JSON object on
|
|
93
109
|
# stdout, or the reply on stdout and anything else on stderr (a
|
|
94
110
|
# question in full) after "chi send: ".
|
|
95
111
|
# @param command [String] "chi send", "chi answer"
|
|
112
|
+
# @param extra [Hash] more keys for the JSON object (#json)
|
|
96
113
|
# @return [Integer] the exit status
|
|
97
|
-
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil)
|
|
114
|
+
def report(result, session_id:, stdout:, stderr:, command:, json: false, timeout: nil, extra: {})
|
|
98
115
|
if json
|
|
99
|
-
stdout.puts(json_line(result, session_id: session_id, timeout: timeout))
|
|
116
|
+
stdout.puts(json_line(result, session_id: session_id, timeout: timeout, extra: extra))
|
|
100
117
|
elsif result.status == :done
|
|
101
118
|
stdout.puts(result.text)
|
|
102
119
|
else
|
|
@@ -455,7 +455,7 @@ module Samagotchi
|
|
|
455
455
|
def settle_standing(standing, answer, client_id)
|
|
456
456
|
clear_saved_question
|
|
457
457
|
emit({ type: :question_answered, id: answer[:id], answer: answer })
|
|
458
|
-
standing[:on_answer].call(answer
|
|
458
|
+
standing[:on_answer].call(answer, client_id: client_id)
|
|
459
459
|
rescue StandardError => e
|
|
460
460
|
Log.warn(:turn, "standing_answer_failed", error: e.class.name, message: e.message)
|
|
461
461
|
end
|
|
@@ -472,12 +472,6 @@ module Samagotchi
|
|
|
472
472
|
raise ArgumentError, "invalid selection: #{invalid.join(', ')} (valid: #{opts.join(', ')})" unless invalid.empty?
|
|
473
473
|
raise ArgumentError, "single-select question: got #{sel.size} selections" if !pending[:multi_select] && sel.size > 1
|
|
474
474
|
raise ArgumentError, "selection required" if pending[:multi_select] == false && sel.empty? && fm.nil?
|
|
475
|
-
# A step-limit question: Continue resumes the turn as it was; a text
|
|
476
|
-
# goes with Stop (steering a continue is not a thing).
|
|
477
|
-
if pending[:kind].to_s == CONTINUE_KIND && sel == [CONTINUE_OPTION] && fm
|
|
478
|
-
raise ArgumentError, "Continue takes no text (a text goes with Stop)"
|
|
479
|
-
end
|
|
480
|
-
|
|
481
475
|
indices = sel.map { |v| opts.index(v) }
|
|
482
476
|
# The one whose answer settles the approval (Approval.settle, by index).
|
|
483
477
|
if parent_setting
|
|
@@ -33,7 +33,16 @@ module Samagotchi
|
|
|
33
33
|
# "failed", text is the failure's summary when the turn note has one.
|
|
34
34
|
# @!attribute limit [Integer, nil] the iteration limit an "exhausted"
|
|
35
35
|
# turn ran out at
|
|
36
|
-
|
|
36
|
+
# @!attribute error_kind [String, nil] a "failed" turn's provider error
|
|
37
|
+
# kind (credits, server, auth, …), from last_turn
|
|
38
|
+
# @!attribute retryable [Boolean, nil] whether that error may pass on a
|
|
39
|
+
# retry
|
|
40
|
+
# @!attribute cancel_reason [String, nil] a "canceled" turn's reason
|
|
41
|
+
# (user, hook, ctrl_c, manual)
|
|
42
|
+
# @!attribute stopped_by [String, nil] the hook that canceled it
|
|
43
|
+
# (loop-guard)
|
|
44
|
+
Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, :error_kind, :retryable, :cancel_reason,
|
|
45
|
+
:stopped_by, keyword_init: true)
|
|
37
46
|
|
|
38
47
|
module_function
|
|
39
48
|
|
|
@@ -151,7 +160,8 @@ module Samagotchi
|
|
|
151
160
|
return Result.new(status: :no_reply, outcome: "exhausted", limit: last["limit"]) if fresh && last["exhausted"]
|
|
152
161
|
|
|
153
162
|
text = outcome == "failed" ? failure_summary(session.messages) : nil
|
|
154
|
-
|
|
163
|
+
why = fresh ? last.slice("error_kind", "retryable", "cancel_reason", "stopped_by").transform_keys(&:to_sym) : {}
|
|
164
|
+
Result.new(status: :no_reply, outcome: outcome, text: text, **why)
|
|
155
165
|
end
|
|
156
166
|
|
|
157
167
|
# The summary in the failed-turn note at the conversation's tail, nil
|
|
@@ -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
|
|
@@ -23,6 +23,7 @@ module Samagotchi
|
|
|
23
23
|
@controller = nil
|
|
24
24
|
@sink = nil
|
|
25
25
|
@steers = []
|
|
26
|
+
@carried_steers = []
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
# ── Activity clock ──────────────────────────────────────────────────
|
|
@@ -69,6 +70,8 @@ module Samagotchi
|
|
|
69
70
|
@running = true
|
|
70
71
|
@controller = controller
|
|
71
72
|
@sink = sink
|
|
73
|
+
@steers.concat(@carried_steers)
|
|
74
|
+
@carried_steers = []
|
|
72
75
|
end
|
|
73
76
|
end
|
|
74
77
|
|
|
@@ -118,6 +121,25 @@ module Samagotchi
|
|
|
118
121
|
true
|
|
119
122
|
end
|
|
120
123
|
|
|
124
|
+
# Queue +text+ for the next turn that begins: it is a steer of that turn
|
|
125
|
+
# from its first boundary on (a continue turn's answer text, which has
|
|
126
|
+
# no turn running yet to steer). Taken by #begin!.
|
|
127
|
+
# @return [Boolean] whether it was queued (false: blank)
|
|
128
|
+
def steer_next_turn(text, source:)
|
|
129
|
+
text = text.to_s.strip
|
|
130
|
+
return false if text.empty?
|
|
131
|
+
|
|
132
|
+
@lock.synchronize { @carried_steers << { text: text, source: source.to_s } }
|
|
133
|
+
true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Drop what #steer_next_turn queued and no turn began with (a no-op
|
|
137
|
+
# once #begin! took it).
|
|
138
|
+
# @return [Array<Hash>] the steers dropped ({text:, source:})
|
|
139
|
+
def drop_carried_steers
|
|
140
|
+
@lock.synchronize { @carried_steers.tap { @carried_steers = [] } }
|
|
141
|
+
end
|
|
142
|
+
|
|
121
143
|
# @return [Array<Hash>] the steers queued so far ({text:, source:}), taken
|
|
122
144
|
def take_steers
|
|
123
145
|
@lock.synchronize { @steers.tap { @steers = [] } }
|