samagotchi 0.50.0 → 0.51.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -1
  3. data/docs/cli.md +21 -7
  4. data/docs/configuration.md +11 -9
  5. data/docs/hooks.md +3 -2
  6. data/docs/internals/engine.md +4 -2
  7. data/docs/sessions.md +5 -4
  8. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  9. data/lib/samagotchi/bridge.rb +24 -9
  10. data/lib/samagotchi/bridge_client.rb +3 -1
  11. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  12. data/lib/samagotchi/delivery.rb +33 -0
  13. data/lib/samagotchi/host_registry.rb +16 -3
  14. data/lib/samagotchi/kernel_loop.rb +28 -2
  15. data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -2
  16. data/lib/samagotchi/send_command.rb +63 -10
  17. data/lib/samagotchi/session_commands.rb +9 -0
  18. data/lib/samagotchi/session_inbox.rb +27 -7
  19. data/lib/samagotchi/session_manager.rb +13 -3
  20. data/lib/samagotchi/session_metrics.rb +25 -6
  21. data/lib/samagotchi/sessions_command.rb +1 -1
  22. data/lib/samagotchi/steer_cut.rb +12 -8
  23. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -1
  24. data/lib/samagotchi/terminal_ui/formatting.rb +51 -4
  25. data/lib/samagotchi/terminal_ui.rb +48 -5
  26. data/lib/samagotchi/tool_activity.rb +3 -0
  27. data/lib/samagotchi/tool_runner.rb +2 -0
  28. data/lib/samagotchi/tools/builtin_calls.rb +33 -0
  29. data/lib/samagotchi/version.rb +1 -1
  30. data/lib/samagotchi/web/app.rb +6 -2
  31. data/lib/samagotchi/web/message_parts.rb +3 -0
  32. data/lib/samagotchi/web/public/activity.js +6 -0
  33. data/lib/samagotchi/web/public/app.js +66 -28
  34. data/lib/samagotchi/web/public/index.html +17 -15
  35. data/lib/samagotchi/web/public/session_title.js +26 -0
  36. data/lib/samagotchi/web/public/strip.js +4 -6
  37. data/lib/samagotchi/web/public/turn_events.js +3 -2
  38. data/lib/samagotchi/web/public/turn_html.js +3 -1
  39. data/lib/samagotchi/worker.rb +1 -1
  40. data/lib/samagotchi/worker_inbound.rb +8 -5
  41. data/lib/samagotchi.rb +1 -0
  42. metadata +3 -1
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "client_id"
4
+ require_relative "delivery"
4
5
  require_relative "session"
5
6
  require_relative "session_commands"
6
7
  require_relative "session_inbox"
@@ -34,13 +35,14 @@ module Samagotchi
34
35
  MAX_IMAGES = ImageStore::MAX_TURN_REFS
35
36
 
36
37
  USAGE = <<~TEXT
37
- Usage: chi send [-m TEXT] [--image PATH]... (ID|PREFIX)...
38
+ Usage: chi send [-m TEXT] [--image PATH]... [--cut] (ID|PREFIX)...
38
39
  chi send --new [--dir DIR] [--model M] [--llm-context LAYERS] [-m TEXT] [--image PATH]...
39
40
  chi send --new --continues (ID|PREFIX|last:ID) [-m TEXT] [--image PATH]...
40
41
  chi send --wait [--timeout S] [--format json] [-m TEXT] [--image PATH]... (--new | ID)
41
42
  chi send --wait [--timeout S] [--format json] ID
42
43
  Sends a message to each session, as if typed in it: a turn starts,
43
- or a running one picks it up. A stopped session's worker starts.
44
+ or a running one takes it at its next step. A stopped session's
45
+ worker starts.
44
46
  A session command (/model x, !cmd) runs as the command; --wait
45
47
  then has no reply to wait for (exit 0, status command).
46
48
  -m TEXT the message; stdin, when piped too, goes above it as a
@@ -75,6 +77,14 @@ module Samagotchi
75
77
  nothing is sent: it waits for the session's next
76
78
  reply, a running turn's too (after exit 3, 4 or 130,
77
79
  wait again this way)
80
+ --cut ask the running turn to cut the generation it is
81
+ streaming and take the message now (a generation that
82
+ has streamed only thinking for steer.cut_after; else
83
+ the message waits for the thinking to pass it). A
84
+ message without --cut goes in at the running turn's
85
+ next step and cuts nothing. To an idle session it
86
+ sends normally. Refused with --wait into a running
87
+ turn (the steer's turn is the running one)
78
88
  --timeout S (--wait) give up after S seconds, exit 4 (the turn
79
89
  goes on); default no limit
80
90
  --format json
@@ -82,7 +92,8 @@ module Samagotchi
82
92
  status answered (text), question (question,
83
93
  answer_with), running, or failed, canceled,
84
94
  limit, no_answer, error, worker_gone, stopped,
85
- command (detail)
95
+ command (detail); with --cut also delivery (what
96
+ the worker applied) and cut (now, waits, off)
86
97
  Exit with --wait: 0 answered, 1 failed or gone, 2 usage, 3 a
87
98
  question waits, 4 still running (--timeout), 130 Ctrl-C.
88
99
  Only sessions on this machine. Answers show in the attached TUI
@@ -101,6 +112,7 @@ module Samagotchi
101
112
  f.value "--llm-context-budget", key: :llm_budget
102
113
  f.value "--image", key: :images, repeat: true
103
114
  f.switch "--wait"
115
+ f.switch "--cut"
104
116
  f.value "--timeout"
105
117
  f.value "--format"
106
118
  # Starting a turn in every live session at once is too easy to do
@@ -160,6 +172,7 @@ module Samagotchi
160
172
  def run_parsed(options)
161
173
  # With --wait stdout is the answer alone.
162
174
  @info = options[:wait] ? @stderr : @stdout
175
+ @cut = options[:cut] == true
163
176
  prompt = compose(utf8(read_stdin), utf8(options[:message]))
164
177
  # Before the wait-only case, which would drop the images.
165
178
  return usage_error("--image needs a message: pass -m TEXT or pipe it in") if prompt.nil? && !options[:images].empty?
@@ -527,7 +540,11 @@ module Samagotchi
527
540
  owner = SessionManager.session_owner(id, state_dir: @state_dir)
528
541
  running = owner && session.status == Session::STATUS_RUNNING
529
542
  refs = copy_images(Session.session_dir(id, state_dir: @state_dir))
530
- result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir)
543
+ # --cut asks the running turn for a cut; to an idle session it is an
544
+ # ordinary message (there is nothing to cut).
545
+ delivery = @cut && running ? Delivery::CUT : nil
546
+ result = SessionManager.deliver_turn(id, prompt: prompt, client_id: CLIENT_ID, images: refs, state_dir: @state_dir,
547
+ delivery: delivery)
531
548
  unless result[:status] == :accepted
532
549
  @info.puts("#{short} failed: #{result.dig(:ack, "detail") || "could not queue it"}")
533
550
  return false
@@ -539,12 +556,12 @@ module Samagotchi
539
556
  return true
540
557
  end
541
558
 
542
- # A busy worker runs a message with images as its own next turn
543
- # rather than merging it into the running one.
544
- note = if owner.nil? then " (started its worker)"
545
- elsif running then refs.empty? ? " (the running turn picks it up)" : " (runs after the current turn)"
546
- end
547
- @info.puts("#{short} sent#{with_images}#{note}")
559
+ ack = result[:ack].is_a?(Hash) ? result[:ack] : {}
560
+ # What the worker applied, for --format json: its ack's delivery
561
+ # (next_step for an idle session) and a cut's outcome.
562
+ @sent_delivery = ack["delivery"] || delivery
563
+ @sent_cut = ack["cut"]
564
+ @info.puts("#{short} sent#{with_images}#{delivery_note(ack, delivery: delivery, running: running, owner: owner)}")
548
565
  true
549
566
  rescue SessionManager::OwnedByTUI
550
567
  @info.puts("#{short} refused: it is open in a chi REPL; messages need attached mode")
@@ -554,6 +571,42 @@ module Samagotchi
554
571
  false
555
572
  end
556
573
 
574
+ # What the line says became of the message: a plain one goes in at the
575
+ # running turn's next step (nothing cuts by default); a --cut one names
576
+ # the outcome from the ack (now, waits, off); images keep their own
577
+ # wording; an idle session's turn simply runs.
578
+ # @param ack [Hash] the Bridge's 202 body ({} for the input file)
579
+ # @return [String] "" or " (...)"
580
+ def delivery_note(ack, delivery:, running:, owner:)
581
+ return " (started its worker)" if owner.nil?
582
+ return "" unless running
583
+
584
+ # A busy worker runs a message with images as its own next turn
585
+ # rather than merging it into the running one.
586
+ return " (runs after the current turn)" unless @images.empty?
587
+
588
+ case (Delivery.cut?(delivery) ? ack["cut"].to_s : "")
589
+ when "now" then " (cut in now)"
590
+ when "waits" then " (cuts in once the thinking passes #{Config.get("steer.cut_after").to_i} s)"
591
+ when "off" then " (cutting is off; goes in at the next step)"
592
+ # No outcome: a plain message, or the input file (the Bridge went
593
+ # away), which the next step's drain takes.
594
+ else " (goes in at the running turn's next step)"
595
+ end
596
+ end
597
+
598
+ # More keys for --format json, with --cut only (the line is unchanged
599
+ # otherwise): the delivery the worker applied (next_step to an idle
600
+ # session) and a cut's outcome (now, waits, off). In P1 --cut --wait is
601
+ # refused for a running turn, so the json line says next_step.
602
+ def report_extra
603
+ return {} unless @cut
604
+
605
+ extra = { delivery: @sent_delivery || Delivery::NEXT_STEP }
606
+ extra[:cut] = @sent_cut if @sent_cut
607
+ extra
608
+ end
609
+
557
610
  # What to say when +model_name+ is known not to take images, or nil
558
611
  # (it does, or it is unknown: sent as before). Asked once per model.
559
612
  def vision_refusal(model_name, typed: nil)
@@ -100,6 +100,15 @@ module Samagotchi
100
100
  # The REPL owns its session: it answers /detach with a note, and doesn't offer it.
101
101
  registry.register("/detach", "leave and keep the worker running", local: true, uis: [:attached],
102
102
  match: ->(text) { text.casecmp?("/detach") })
103
+ # /cut TEXT and /queue TEXT while a turn runs: a delivery the UIs parse
104
+ # themselves (Formatting#steer_choice) before any command check; the
105
+ # entries exist for Tab completion and /help. /queue is a real
106
+ # after-the-turn list only in the local REPL (the attached TUI's
107
+ # worker doesn't skip queued files yet), so it is offered there.
108
+ registry.register("/cut", "cut the running generation and go in now (/cut TEXT)",
109
+ local: true, match: ->(text) { text.casecmp?("/cut") || text.match?(%r{\A/cut\s+\S}) })
110
+ registry.register("/queue", "run as the next turn after this one (/queue TEXT)",
111
+ local: true, uis: [:repl], match: ->(text) { text.casecmp?("/queue") || text.match?(%r{\A/queue\s+\S}) })
103
112
  registry
104
113
  end
105
114
 
@@ -5,6 +5,7 @@ require "json"
5
5
  require "time"
6
6
  require "securerandom"
7
7
  require_relative "atomic_file"
8
+ require_relative "delivery"
8
9
  require_relative "session"
9
10
 
10
11
  module Samagotchi
@@ -34,12 +35,18 @@ module Samagotchi
34
35
  class NoteRejected < ArgumentError; end
35
36
 
36
37
  # Queue a turn's input file: JSON with the prompt and the sender's ids.
38
+ # +delivery+ is only written when it is "queue": a step-boundary
39
+ # message is the default an older worker assumes, and a cut is done by
40
+ # the sender (the Bridge) at once, not by the file.
37
41
  # @param client_id [String, nil] the sending UI
38
42
  # @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
43
+ # @param delivery [String, nil] the wire value: "next_step" (default),
44
+ # "cut" or "queue"
39
45
  # @param images [Array<Hash>] image refs ({file:, name:}) in the
40
46
  # session's images/
41
47
  # @return [String, false] the input file's path, or false
42
- def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [])
48
+ def self.write_input(session_dir, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, images: [],
49
+ delivery: nil)
43
50
  images = Array(images)
44
51
  input_dir = File.join(session_dir, INPUT_DIR)
45
52
  FileUtils.mkdir_p(input_dir)
@@ -47,6 +54,7 @@ module Samagotchi
47
54
  path = File.join(input_dir, "#{Time.now.strftime("%Y%m%d%H%M%S%9N")}.json")
48
55
  record = { "prompt" => prompt.to_s, "client_id" => client_id, "enqueued_id" => enqueued_id,
49
56
  "no_interrupt" => (no_interrupt ? true : nil),
57
+ "delivery" => (Delivery.queue?(delivery) ? Delivery::QUEUE : nil),
50
58
  "images" => (images.empty? ? nil : images.map { |image| image.transform_keys(&:to_s) }) }.compact
51
59
  AtomicFile.write(path, JSON.generate(record))
52
60
  path
@@ -175,17 +183,20 @@ module Samagotchi
175
183
  Dir.glob(File.join(input_dir, "*.json"))
176
184
  end
177
185
 
178
- # @return [Array(String, Hash|nil, Boolean, Array<Hash>)] a claimed input
179
- # file's prompt, origin ({client_id:, enqueued_id:}, nil when it names
180
- # no sender), whether its turn runs with the raised iteration limit
181
- # (--no-interrupt), and its image refs ({file:, name:})
186
+ # @return [Array(String, Hash|nil, Boolean, Array<Hash>, String|nil)] a
187
+ # claimed input file's prompt, origin ({client_id:, enqueued_id:},
188
+ # nil when it names no sender), whether its turn runs with the raised
189
+ # iteration limit (--no-interrupt), its image refs ({file:, name:}),
190
+ # and its delivery ("queue" or nil; a file without the key is one an
191
+ # older chi wrote, so nil)
182
192
  def self.read_input(claimed_file)
183
193
  data = JSON.parse(File.read(claimed_file).to_s)
184
194
  origin = { client_id: data["client_id"], enqueued_id: data["enqueued_id"] }.compact
185
195
  images = Array(data["images"]).select { |image| image.is_a?(Hash) }.map { |image| image.transform_keys(&:to_sym) }
186
- [data["prompt"].to_s, origin.empty? ? nil : origin, data["no_interrupt"] == true, images]
196
+ [data["prompt"].to_s, origin.empty? ? nil : origin, data["no_interrupt"] == true, images,
197
+ Delivery.queue?(data["delivery"]) ? Delivery::QUEUE : nil]
187
198
  rescue JSON::ParserError
188
- [nil, nil, false, []]
199
+ [nil, nil, false, [], nil]
189
200
  end
190
201
 
191
202
  # Whether an unclaimed input file carries images (a mid-turn drain
@@ -197,6 +208,15 @@ module Samagotchi
197
208
  false
198
209
  end
199
210
 
211
+ # Whether a mid-turn drain leaves this unclaimed input file for a turn
212
+ # of its own instead of merging it at a step boundary. Today that is an
213
+ # image message (steering merges text only); the drain's file filter
214
+ # reads this one predicate, so what a boundary refuses changes in one
215
+ # place.
216
+ def self.waits_for_turn_end?(input_file)
217
+ input_has_images?(input_file)
218
+ end
219
+
200
220
  def self.claim_input_file(input_file)
201
221
  processing_path = "#{input_file}.processing"
202
222
  File.rename(input_file, processing_path)
@@ -8,6 +8,7 @@ require "rbconfig"
8
8
  require_relative "client_id"
9
9
  require_relative "atomic_file"
10
10
  require_relative "config"
11
+ require_relative "delivery"
11
12
 
12
13
  require_relative "session"
13
14
  require_relative "session_inbox"
@@ -1288,14 +1289,16 @@ module Samagotchi
1288
1289
  # boundary. The file is JSON carrying the sender's ids.
1289
1290
  # @param client_id [String, nil] the sending UI
1290
1291
  # @param enqueued_id [String, nil] the id its ACK / :turn_enqueued carry
1292
+ # @param delivery [String, nil] the wire value: "next_step" (default),
1293
+ # "cut" or "queue"
1291
1294
  # @param images [Array<Hash>] image refs ({file:, name:}) in the
1292
1295
  # session's images/
1293
1296
  # @return [String, false] the input file's path, or false.
1294
1297
  def self.write_turn_input(session_id, prompt:, client_id: nil, enqueued_id: nil, no_interrupt: false, state_dir: nil,
1295
- images: [])
1298
+ images: [], delivery: nil)
1296
1299
  session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1297
1300
  SessionInbox.write_input(session_dir, prompt: prompt, client_id: client_id, enqueued_id: enqueued_id,
1298
- no_interrupt: no_interrupt, images: images)
1301
+ no_interrupt: no_interrupt, images: images, delivery: delivery)
1299
1302
  end
1300
1303
 
1301
1304
  # How long a turn waits for the Bridge of a worker a resume just spawned.
@@ -1308,6 +1311,8 @@ module Samagotchi
1308
1311
  # exit). Race-safe against a TUI taking the session, or the worker
1309
1312
  # exiting, between the resume and the write.
1310
1313
  # @param images [Array<Hash>] refs ({file:, name:}) already in images/
1314
+ # @param delivery [String, nil] what the message asks the running turn
1315
+ # for: "next_step" (default), "cut" or "queue"
1311
1316
  # @param manager [#resume_session, #write_turn_input] this class, or a
1312
1317
  # stand-in (the web's specs); #session_owner is optional
1313
1318
  # @param bridge [#call, nil] returns the BridgeClient or nil; defaults to
@@ -1319,7 +1324,8 @@ module Samagotchi
1319
1324
  # {status: :failed} when the input file couldn't be written
1320
1325
  # @raise [OwnedByTUI] a chi REPL owns the session
1321
1326
  # @raise [ArgumentError] no such session
1322
- def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], state_dir: nil, manager: self, bridge: nil)
1327
+ def self.deliver_turn(session_id, prompt:, client_id: nil, images: [], delivery: nil, state_dir: nil, manager: self,
1328
+ bridge: nil)
1323
1329
  session_dir = Session.session_dir(session_id, state_dir: state_dir || Session.default_state_dir)
1324
1330
  bridge ||= -> { BridgeClient.wait_for(session_id, session_dir: session_dir, timeout: TURN_BRIDGE_WAIT) }
1325
1331
  manager.resume_session(session_id, state_dir: state_dir)
@@ -1327,6 +1333,9 @@ module Samagotchi
1327
1333
  begin
1328
1334
  options = { prompt: prompt, client_id: client_id }
1329
1335
  options[:images] = images unless images.empty?
1336
+ # An old worker ignores the field: it cuts anyway (its own default)
1337
+ # and its file never carries a queue, so a queue would steer there.
1338
+ options[:delivery] = delivery unless Delivery.next_step?(delivery)
1330
1339
  reply = client.post_turn(**options)
1331
1340
  ack = reply.json
1332
1341
  return { status: :accepted, ack: ack } if reply.status == 202 && ack.is_a?(Hash)
@@ -1347,6 +1356,7 @@ module Samagotchi
1347
1356
  enqueued_id = SecureRandom.uuid
1348
1357
  input = { prompt: prompt, client_id: client_id, enqueued_id: enqueued_id, state_dir: state_dir }
1349
1358
  input[:images] = images unless images.empty?
1359
+ input[:delivery] = delivery unless Delivery.next_step?(delivery)
1350
1360
  path = manager.write_turn_input(session_id, **input)
1351
1361
  return { status: :failed } unless path
1352
1362
 
@@ -379,6 +379,16 @@ module Samagotchi
379
379
  @mutex.synchronize { build_snapshot(:recent) }
380
380
  end
381
381
 
382
+ # The snapshot of a session no worker runs, read from disk (`chi
383
+ # sessions stats`): #snapshot's, but its records are the newest saved
384
+ # turn's (and its tool calls), as a live worker's snapshot carries its
385
+ # newest finished turn; #snapshot would list none, this collector having
386
+ # finished no turn of its own.
387
+ # @return [Hash]
388
+ def saved_snapshot
389
+ @mutex.synchronize { build_snapshot(:last) }
390
+ end
391
+
382
392
  # Close the open generation now, ahead of its :generation_completed
383
393
  # (which then finds it closed), and report its speed with the session's
384
394
  # running token totals: what the event carries to the UIs.
@@ -441,6 +451,9 @@ module Samagotchi
441
451
  served_model_for: @served_model_for,
442
452
  tool_calls_total: @tool_records.size + in_flight.size,
443
453
  tool_calls_by_tool: tally_into(totals[:by_tool].dup, in_flight),
454
+ # The calls the model made under an alias's name (bash run as
455
+ # execute), by its own spelling; only when there are any.
456
+ **(totals[:aliased].empty? ? {} : { tool_calls_aliased: totals[:aliased].dup }),
444
457
  tool_errors: totals[:tool_errors],
445
458
  iterations_total: totals[:iterations] + (turn&.iteration_count || 0),
446
459
  gen_latency_ms: (totals[:gen_ms] + (turn&.gen_latency_accum || 0)).round,
@@ -486,14 +499,16 @@ module Samagotchi
486
499
 
487
500
  # The records a snapshot carries, copied. :all is every record; :recent
488
501
  # the newest turn this collector finished (live before its save lands)
489
- # and the unsaved ones (capped), and the tool records of those turns and
490
- # of the running one. Records are appended in turn order, so both come
491
- # from the tails. Caller holds the mutex.
502
+ # and the unsaved ones (capped); :last the newest turn, loaded or not;
503
+ # and the tool records of those turns and of the running one. Records
504
+ # are appended in turn order, so both come from the tails. Caller holds
505
+ # the mutex.
492
506
  def records_view(mode)
493
507
  return { turn_records: @turn_records.map(&:dup), tool_records: @tool_records.map(&:dup) } if mode == :all
494
508
 
495
509
  unsaved = @turn_records.size - @persisted_turns
496
- count = unsaved.clamp(@turn_records.size > @loaded_turns ? 1 : 0, LIVE_RECORDS_CAP)
510
+ fresh = @turn_records.size > @loaded_turns ? 1 : 0
511
+ count = mode == :last ? 1 : unsaved.clamp(fresh, LIVE_RECORDS_CAP)
497
512
  turns = @turn_records.last(count)
498
513
  ids = turns.to_set { |record| record[:id] }
499
514
  ids << @turn.id if @turn
@@ -807,6 +822,8 @@ module Samagotchi
807
822
  active = @turn.tool_calls_by_id.delete(key)
808
823
  return unless active
809
824
 
825
+ called_as = event.dig(:activity, :called_as)
826
+ active[:called_as] = called_as.to_s if called_as
810
827
  finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
811
828
  end
812
829
  end
@@ -814,7 +831,7 @@ module Samagotchi
814
831
  # Caller holds the mutex.
815
832
  def finish_tool_record(active, status, waited_ms: 0)
816
833
  record = active.slice(
817
- :id, :turn_id, :iteration, :call_index, :tool, :started_at
834
+ :id, :turn_id, :iteration, :call_index, :tool, :called_as, :started_at
818
835
  ).merge(
819
836
  status: status,
820
837
  completed_at: now.iso8601(3),
@@ -1020,7 +1037,7 @@ module Samagotchi
1020
1037
  # (#snapshot adds the running turn). Caller holds the mutex.
1021
1038
  def reset_totals
1022
1039
  @totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
1023
- gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
1040
+ gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, aliased: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
1024
1041
  reprefill_sum: 0, reasoning_sum: 0,
1025
1042
  cost_sum: 0, cost_estimate_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
1026
1043
  last_prefill_tps: nil, tps_source: nil }
@@ -1057,6 +1074,8 @@ module Samagotchi
1057
1074
  def count_tool(record)
1058
1075
  @totals[:tool_errors] += 1 if record[:status] == "error"
1059
1076
  tally_into(@totals[:by_tool], [record])
1077
+ called_as = record[:called_as].to_s
1078
+ @totals[:aliased][called_as] = @totals[:aliased].fetch(called_as, 0) + 1 unless called_as.empty?
1060
1079
  end
1061
1080
 
1062
1081
  def tally_into(counts, tools)
@@ -339,7 +339,7 @@ module Samagotchi
339
339
  end
340
340
  return [nil, false, nil] unless File.file?(File.join(dir, "analytics.json"))
341
341
 
342
- snapshot = Samagotchi::SessionMetrics.new.tap { |m| m.session_id = session.id }.snapshot
342
+ snapshot = Samagotchi::SessionMetrics.new.tap { |m| m.session_id = session.id }.saved_snapshot
343
343
  note = live_worker?(session.id) ? "(no live worker answered; from the saved analytics.json)" : nil
344
344
  [snapshot, false, note]
345
345
  end
@@ -44,21 +44,25 @@ module Samagotchi
44
44
  # Call it after the message is queued, with the #input_epoch read
45
45
  # before queueing it. Outside any lock; a cut that lands just after the
46
46
  # generation ended is harmless (the loop re-asks).
47
- # @return [Boolean] whether a generation was cut now
47
+ # @return [Symbol] :now (a generation was cut now), :waits (the message
48
+ # waits for the thinking to pass steer.cut_after), or :off (cutting
49
+ # off for this source: a plugin, no turn, cut_after 0, a generation
50
+ # that isn't thinking-only, or a drain already took the message)
48
51
  def cut_for_steer(source, epoch: nil)
49
- return false unless Steer.cuts?(source)
52
+ return :off unless Steer.cuts?(source)
50
53
 
51
54
  after = Config.get("steer.cut_after").to_i
52
- return false unless after.positive?
55
+ return :off unless after.positive?
53
56
 
54
57
  ctrl = @controller.call
55
- return false unless ctrl
56
- return cut!(ctrl, source.to_s) if @phase.cuttable?(after)
58
+ return :off unless ctrl
59
+ return cut!(ctrl, source.to_s) ? :now : :off if @phase.cuttable?(after)
57
60
 
58
- Log.info(:turn, "steer_cut_waits", source: source.to_s) if @waiting.wait!(source, epoch: epoch)
59
- false
61
+ waited = @waiting.wait!(source, epoch: epoch)
62
+ Log.info(:turn, "steer_cut_waits", source: source.to_s) if waited
63
+ waited ? :waits : :off
60
64
  rescue StandardError
61
- false
65
+ :off
62
66
  end
63
67
 
64
68
  # The drains that took input so far (WaitingSteer#epoch): read it before
@@ -14,6 +14,7 @@ require_relative "reline_seam"
14
14
  require_relative "version_lines"
15
15
  require_relative "../bridge/turn_accumulator"
16
16
  require_relative "../bridge_client"
17
+ require_relative "../delivery"
17
18
  require_relative "../log"
18
19
  require_relative "../context_note"
19
20
  require_relative "../steer"
@@ -380,6 +381,14 @@ module Samagotchi
380
381
  return answer_question(text)
381
382
  end
382
383
 
384
+ # /cut TEXT and /queue TEXT are this UI's own deliveries, read before
385
+ # every command check (SessionCommands registers them for Tab and
386
+ # /help only). A plain line sends no delivery: it goes in at the
387
+ # running turn's next step, and never cuts.
388
+ if (choice = steer_choice(text))
389
+ return submit_delivery(*choice)
390
+ end
391
+
383
392
  # The terminal's own commands (SessionCommands registers them, the
384
393
  # REPL reads the same words). Before the continue offer: the
385
394
  # leaving ones are no answer to it.
@@ -414,6 +423,20 @@ module Samagotchi
414
423
  nil
415
424
  end
416
425
 
426
+ # /cut TEXT and /queue TEXT while a turn runs. Only /cut goes in P1:
427
+ # the worker's drain doesn't skip queued files yet, so a queued
428
+ # message would steer the running turn; /queue says so and sends
429
+ # nothing. At the prompt both run the text as a normal turn.
430
+ # @return [nil]
431
+ def submit_delivery(choice, text)
432
+ return @screen.commit(STEER_CHOICE_USAGE) if text.empty?
433
+ return @screen.commit(QUEUE_UNAVAILABLE) if choice == :queue
434
+
435
+ # To an idle session it is an ordinary prompt: no delivery to send.
436
+ send_prompt(text, delivery: @running ? Delivery::CUT : nil)
437
+ nil
438
+ end
439
+
417
440
  # @return [Symbol] :detach
418
441
  def detach(line)
419
442
  @view.finish_thinking_spinner
@@ -573,6 +596,9 @@ module Samagotchi
573
596
  end
574
597
 
575
598
  QUEUED_NOTE = "(queued: runs after this turn)"
599
+ # /queue needs a worker whose drain skips queued input files (P3):
600
+ # until then a queued message would steer the running turn.
601
+ QUEUE_UNAVAILABLE = "(/queue isn't available in an attached session yet)"
576
602
 
577
603
  # A command queued for after the running turn (command_queued with
578
604
  # waits): its line now (another client's) and a note; its command_ran
@@ -661,19 +687,40 @@ module Samagotchi
661
687
  end
662
688
  end
663
689
 
664
- def send_prompt(text)
690
+ # What a prompt's 202 says became of it, or nil when there is nothing
691
+ # to say: the dim line both TUIs print for a delivery. A plain line
692
+ # for a running turn goes in at its next step; a cut's outcome comes
693
+ # from the ack (the worker knows whether cutting is on for its model).
694
+ # An idle session's prompt simply runs, and a prompt with images runs
695
+ # as its own next turn (never merging mid-turn), so nothing is said.
696
+ # @param ack [Hash, nil] the Bridge's 202 body
697
+ # @param delivery [String, nil] what was asked for
698
+ # @return [String, nil]
699
+ def sent_note(ack, delivery: nil, images: [], running: false)
700
+ return nil unless running && images.empty? && ack.is_a?(Hash)
701
+
702
+ return NEXT_STEP_NOTE unless delivery == Delivery::CUT
703
+
704
+ steer_cut_note(ack["cut"].to_s.to_sym)
705
+ end
706
+
707
+ def send_prompt(text, delivery: nil)
665
708
  persist_recent_history(text)
666
709
  images = attach_images(text)
667
710
  return own_prompt_failed if images.nil?
668
711
 
712
+ running = @running
669
713
  options = { prompt: text, client_id: @client_id }
670
714
  options[:no_interrupt] = true if @no_interrupt
671
715
  options[:images] = images unless images.empty?
716
+ options[:delivery] = delivery if delivery
672
717
  reply = @client.post_turn(**options)
673
718
  if reply.status == 202
674
719
  enqueued_id = reply.json&.fetch("enqueued_id", nil)
675
720
  @sent_ids << enqueued_id if enqueued_id
676
721
  @open_ids << enqueued_id if enqueued_id && @wait_at_eof
722
+ note = sent_note(reply.json, delivery: delivery, images: images, running: running)
723
+ @screen.commit(note) if note
677
724
  return
678
725
  end
679
726
 
@@ -31,13 +31,17 @@ module Samagotchi
31
31
 
32
32
  # What a tool line says the call did: the model's description of a
33
33
  # command (": List the specs") in place of its cut params, else the
34
- # params, dim.
34
+ # params, dim; then, dim, the name the model called it by when that was
35
+ # an alias's (" (called as bash)").
35
36
  def tool_params_suffix(activity)
36
37
  description = activity[:description].to_s.strip
37
- return ": #{description}" unless description.empty?
38
-
39
38
  params = activity[:params].to_s.strip
40
- params.empty? ? "" : " #{paint(params, 90)}"
39
+ suffix = if !description.empty? then ": #{description}"
40
+ elsif !params.empty? then " #{paint(params, 90)}"
41
+ else ""
42
+ end
43
+ called_as = activity[:called_as].to_s
44
+ called_as.empty? ? suffix : "#{suffix}#{paint(" (called as #{called_as})", 90)}"
41
45
  end
42
46
 
43
47
  # Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
@@ -91,6 +95,45 @@ module Samagotchi
91
95
  paint("↪ cut in for #{STEER_CUT_FOR.fetch(event[:source].to_s, "your message")}", 90)
92
96
  end
93
97
 
98
+ # What a line submitted at the open prompt says became of it: a plain
99
+ # line goes in at the running turn's next step boundary (nothing cuts
100
+ # unless the message asked for it), /cut's cutting now or once the
101
+ # thinking passes steer.cut_after, and /queue's waiting for the end.
102
+ # Both TUIs print the same words (SteerCut#cut_for_steer's answer).
103
+ NEXT_STEP_NOTE = "(goes in at the next step)"
104
+ CUT_NOW_NOTE = "(cut in now)"
105
+ CUT_WAITS_NOTE = "(cuts in once the thinking passes %d s)"
106
+ CUT_OFF_NOTE = "(cutting is off; goes in at the next step)"
107
+ STEER_CHOICE_USAGE = "(/cut and /queue need a message: /cut TEXT, /queue TEXT)"
108
+ # The UIs' own words for a delivery, listed for Tab and /help only
109
+ # (a local: entry, so no command or bundle may use the names).
110
+ CUT_COMMAND = "/cut"
111
+ QUEUE_COMMAND = "/queue"
112
+ # The word, then any whitespace and the text (as the registry's
113
+ # entries match them, so a line Tab or /help knows is never a prompt).
114
+ STEER_CHOICE = /\A(#{CUT_COMMAND}|#{QUEUE_COMMAND})(?:\s+(.*))?\z/m
115
+
116
+ # @param outcome [Symbol] SteerCut#cut_for_steer's answer: :now, :waits or :off
117
+ # @return [String] the dim line to show for a /cut
118
+ def steer_cut_note(outcome)
119
+ case outcome
120
+ when :now then CUT_NOW_NOTE
121
+ when :waits then format(CUT_WAITS_NOTE, Config.get("steer.cut_after").to_i)
122
+ else CUT_OFF_NOTE
123
+ end
124
+ end
125
+
126
+ # The choice a mid-turn line carries, as [:cut, "TEXT"] or
127
+ # [:queue, "TEXT"]; nil for any other line. The text is "" when the
128
+ # line named the choice without one.
129
+ # @param line [String, nil]
130
+ # @return [Array(Symbol, String), nil]
131
+ def steer_choice(line)
132
+ match = STEER_CHOICE.match(line.to_s.strip) or return nil
133
+
134
+ [match[1] == CUT_COMMAND ? :cut : :queue, match[2].to_s.strip]
135
+ end
136
+
94
137
  # "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
95
138
  # off)": a batch of LLM context edits went in (LLMContextNotice builds
96
139
  # the line; the web prints the same), dim like the retry row.
@@ -516,6 +559,10 @@ module Samagotchi
516
559
  by_tool = snapshot[:tool_calls_by_tool].sort_by { |_k, v| -v }
517
560
  lines << " by tool: #{by_tool.map { |k, v| "#{k}=#{v}" }.join(", ")}"
518
561
  end
562
+ unless snapshot[:tool_calls_aliased].to_a.empty?
563
+ aliased = snapshot[:tool_calls_aliased].sort_by { |_k, v| -v }
564
+ lines << " called as: #{aliased.map { |k, v| "#{k}=#{v}" }.join(", ")}"
565
+ end
519
566
  lines << "iterations: #{snapshot[:iterations_total]}"
520
567
  # Summed over every request: each prompt is sent in full again.
521
568
  lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})" \