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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -1
  3. data/docs/architecture.md +5 -3
  4. data/docs/cli.md +2 -1
  5. data/docs/configuration.md +11 -0
  6. data/docs/plugins.md +22 -8
  7. data/docs/sessions.md +3 -3
  8. data/docs/sub-agent.md +11 -5
  9. data/lib/samagotchi/answer_command.rb +7 -3
  10. data/lib/samagotchi/bridge/sse_writer.rb +32 -0
  11. data/lib/samagotchi/bridge/turn_accumulator.rb +10 -3
  12. data/lib/samagotchi/bridge.rb +16 -4
  13. data/lib/samagotchi/bridge_client.rb +33 -9
  14. data/lib/samagotchi/bundle_command.rb +6 -0
  15. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  16. data/lib/samagotchi/bundles/loop-guard/plugin.rb +61 -5
  17. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  18. data/lib/samagotchi/cli/parent_wait.rb +9 -2
  19. data/lib/samagotchi/continue_offer.rb +34 -9
  20. data/lib/samagotchi/engine.rb +47 -4
  21. data/lib/samagotchi/events.rb +1 -1
  22. data/lib/samagotchi/llm/chat_loop.rb +1 -1
  23. data/lib/samagotchi/llm/errors.rb +31 -0
  24. data/lib/samagotchi/llm/openai_chat.rb +33 -7
  25. data/lib/samagotchi/llm/prompt_cache.rb +83 -0
  26. data/lib/samagotchi/log_subscriber.rb +4 -2
  27. data/lib/samagotchi/parent_report.rb +24 -7
  28. data/lib/samagotchi/question_desk.rb +1 -7
  29. data/lib/samagotchi/reply_wait.rb +12 -2
  30. data/lib/samagotchi/session_manager.rb +26 -9
  31. data/lib/samagotchi/session_metrics.rb +6 -4
  32. data/lib/samagotchi/steer.rb +1 -1
  33. data/lib/samagotchi/terminal_ui/attached_loop.rb +18 -9
  34. data/lib/samagotchi/terminal_ui/event_renderer.rb +1 -1
  35. data/lib/samagotchi/terminal_ui/formatting.rb +7 -2
  36. data/lib/samagotchi/terminal_ui/live_region.rb +41 -2
  37. data/lib/samagotchi/terminal_ui/reline_seam.rb +10 -0
  38. data/lib/samagotchi/turn_state.rb +22 -0
  39. data/lib/samagotchi/version.rb +1 -1
  40. data/lib/samagotchi/web/public/app.js +34 -7
  41. data/lib/samagotchi/web/public/index.html +7 -0
  42. data/lib/samagotchi/web/public/notifications.js +8 -3
  43. data/lib/samagotchi/web/public/notify.js +49 -7
  44. data/lib/samagotchi/web/public/timing.js +5 -3
  45. data/lib/samagotchi/web/public/turn_events.js +16 -0
  46. 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], **origin(event))
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], requested_model: event[:requested_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 report[:detail] = detail(result, session_id: session_id, timeout: timeout)
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.except(:by), client_id: client_id)
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
- Result = Struct.new(:status, :text, :file, :question, :outcome, :limit, keyword_init: true)
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
- Result.new(status: :no_reply, outcome: outcome, text: text)
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, or it is a
313
- # scratch session
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
- owner = owners[sid]
323
- next unless owner
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", ...), so
461
- # a reloaded web history can show the cancel line the live one did.
462
- def end_turn(status: "completed", reason: nil)
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)
@@ -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 UIs; given, the drain is told it is the after-answer site (plugin
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
- @delete_session.call(id) unless discards?(reply)
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, a
433
- # queued prompt) is stopped by the archive, as the web's archive
434
- # stops it; a turn running refuses it. An empty session: the worker
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("Detached; the session was empty, so it is discarded.") if reply.status == 200 && discards?(reply)
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
- # Said as the worker agreed to leave; a note coming in before it
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
- def turn_canceled_line(reason, duration_ms)
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
- # Hand Reline its own drawing back, then close the surface.
26
- def close(surface)
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 = [] } }
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.20.0"
4
+ VERSION = "0.22.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end