samagotchi 0.49.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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -1
  3. data/bin/chi +2 -2
  4. data/docs/cli.md +33 -8
  5. data/docs/configuration.md +11 -9
  6. data/docs/hooks.md +12 -3
  7. data/docs/internals/engine.md +4 -2
  8. data/docs/sessions.md +8 -4
  9. data/docs/testing.md +2 -1
  10. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  11. data/lib/samagotchi/bridge.rb +24 -9
  12. data/lib/samagotchi/bridge_client.rb +3 -1
  13. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  14. data/lib/samagotchi/bundles/loop-guard/plugin.rb +13 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  16. data/lib/samagotchi/delivery.rb +33 -0
  17. data/lib/samagotchi/engine.rb +1 -1
  18. data/lib/samagotchi/hooks/stream_watch.rb +9 -2
  19. data/lib/samagotchi/host_registry.rb +17 -4
  20. data/lib/samagotchi/kernel_loop.rb +28 -2
  21. data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -2
  22. data/lib/samagotchi/send_command.rb +124 -10
  23. data/lib/samagotchi/session_commands.rb +10 -2
  24. data/lib/samagotchi/session_inbox.rb +27 -7
  25. data/lib/samagotchi/session_manager.rb +13 -3
  26. data/lib/samagotchi/session_metrics.rb +32 -6
  27. data/lib/samagotchi/sessions_command.rb +84 -7
  28. data/lib/samagotchi/steer_cut.rb +12 -8
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +50 -3
  30. data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
  31. data/lib/samagotchi/terminal_ui/formatting.rb +68 -6
  32. data/lib/samagotchi/terminal_ui.rb +48 -5
  33. data/lib/samagotchi/tool_activity.rb +3 -0
  34. data/lib/samagotchi/tool_runner.rb +2 -0
  35. data/lib/samagotchi/tools/builtin_calls.rb +33 -0
  36. data/lib/samagotchi/version.rb +1 -1
  37. data/lib/samagotchi/web/app.rb +6 -2
  38. data/lib/samagotchi/web/message_parts.rb +3 -0
  39. data/lib/samagotchi/web/public/activity.js +6 -0
  40. data/lib/samagotchi/web/public/app.js +71 -29
  41. data/lib/samagotchi/web/public/index.html +17 -15
  42. data/lib/samagotchi/web/public/live_timing.js +32 -5
  43. data/lib/samagotchi/web/public/session_title.js +26 -0
  44. data/lib/samagotchi/web/public/stage_model.js +15 -0
  45. data/lib/samagotchi/web/public/stage_view.js +10 -4
  46. data/lib/samagotchi/web/public/strip.js +4 -6
  47. data/lib/samagotchi/web/public/timing.js +11 -0
  48. data/lib/samagotchi/web/public/turn_events.js +14 -2
  49. data/lib/samagotchi/web/public/turn_html.js +24 -1
  50. data/lib/samagotchi/web/public/turn_view.js +9 -3
  51. data/lib/samagotchi/web/session_hub.rb +11 -3
  52. data/lib/samagotchi/worker.rb +1 -1
  53. data/lib/samagotchi/worker_inbound.rb +8 -5
  54. data/lib/samagotchi.rb +1 -0
  55. metadata +3 -1
@@ -9,19 +9,22 @@ require_relative "session_metrics"
9
9
  require_relative "recap_store"
10
10
  require_relative "session_delete_command"
11
11
  require_relative "session_archive_command"
12
+ require_relative "bridge_client"
13
+ require_relative "terminal_ui/formatting"
14
+ require_relative "terminal_ui/event_renderer"
12
15
  require_relative "cli/command"
13
16
  require_relative "cli/flags"
14
17
 
15
18
  module Samagotchi
16
- # `chi sessions`: list, stop, restart, archive/unarchive, delete, prune and clean
17
- # sessions from the shell (bin/chi dispatches here before OptionParser;
18
- # the subcommands have their own flags). Not SessionCommands, the REPL's
19
- # slash commands for sessions (session_commands.rb).
19
+ # `chi sessions`: list, stop, restart, archive/unarchive, delete, prune,
20
+ # clean and stats sessions from the shell (bin/chi dispatches here before
21
+ # OptionParser; the subcommands have their own flags). Not SessionCommands,
22
+ # the REPL's slash commands for sessions (session_commands.rb).
20
23
  class SessionsCommand
21
24
  include CLI::Command
22
25
 
23
26
  USAGE = <<~TEXT
24
- Usage: chi sessions <list|stop|restart|archive|unarchive|delete|prune|clean> [options]
27
+ Usage: chi sessions <list|stop|restart|archive|unarchive|delete|prune|clean|stats> [options]
25
28
  list [--sort updated_at|created_at] [--order desc|asc] [--limit N]
26
29
  [--live] [--cwd PATH] [--format text|json|tsv] [--archived]
27
30
  --live: sessions a worker runs now (the ones chi note reaches), 10 unless --limit
@@ -34,12 +37,18 @@ module Samagotchi
34
37
  delete [--force] ID... # delete sessions for good (IDs or unique prefixes); --force stops a live worker first
35
38
  prune [--dry-run] [--days N] [--keep N] [--keep-status running,...] [--test-only]
36
39
  clean [--dry-run] [--days N] # test sessions (SAMAGOTCHI_ENV=test, CI) and leftover chi scratch ones: all of them, or those older than N days
40
+ stats ID [--format text|json] # a session's cost, tokens and progress, without running a model turn:
41
+ a live worker's GET stats, else the snapshot its saved analytics.json rebuilds (never starts a worker)
37
42
  Defaults: days=14 keep=500 keep_status=none (config: session.retention_days, session.max_count, session.keep_status)
38
43
  TEXT
39
44
 
40
45
  STOP_USAGE = "Usage: chi sessions stop ID...\n"
41
46
  RESTART_USAGE = "Usage: chi sessions restart ID...\n"
42
- SUBCOMMANDS = %w[list stop restart archive unarchive delete prune clean].freeze
47
+ STATS_USAGE = "Usage: chi sessions stats ID [--format text|json]\n"
48
+ # How long `stats` waits for a live worker's GET stats before reading the
49
+ # saved analytics.json instead (a stuck worker must not hold a driver).
50
+ STATS_TIMEOUT = 3
51
+ SUBCOMMANDS = %w[list stop restart archive unarchive delete prune clean stats].freeze
43
52
 
44
53
  # Each subcommand's flags ("--flag V" or "--flag=V" for a value): an
45
54
  # unknown flag, a value flag with nothing after it or an argument where
@@ -53,6 +62,9 @@ module Samagotchi
53
62
  end,
54
63
  "stop" => CLI::Flags.new,
55
64
  "restart" => CLI::Flags.new,
65
+ "stats" => CLI::Flags.new do |f|
66
+ f.value "--format"
67
+ end,
56
68
  "prune" => CLI::Flags.new(args: false) do |f|
57
69
  %w[--days --keep --keep-status].each { |name| f.value name }
58
70
  f.switch "--dry-run"
@@ -98,6 +110,7 @@ module Samagotchi
98
110
  when "list" then list
99
111
  when "stop" then stop(parsed.args)
100
112
  when "restart" then restart(parsed.args)
113
+ when "stats" then stats(parsed.args)
101
114
  else prune(@sub)
102
115
  end
103
116
  end
@@ -105,7 +118,7 @@ module Samagotchi
105
118
  private
106
119
 
107
120
  def command_name = @sub && SUBCOMMANDS.include?(@sub) ? "chi sessions #{@sub}" : "chi sessions"
108
- def usage_text = { "stop" => STOP_USAGE, "restart" => RESTART_USAGE }.fetch(@sub, USAGE)
121
+ def usage_text = { "stop" => STOP_USAGE, "restart" => RESTART_USAGE, "stats" => STATS_USAGE }.fetch(@sub, USAGE)
109
122
 
110
123
  # quirk: a number that isn't one is 0 (--days=abc turns the age limit off)
111
124
  def integers(options)
@@ -269,6 +282,70 @@ module Samagotchi
269
282
  0
270
283
  end
271
284
 
285
+ # One session's cost, tokens and progress, for a script or an agent that
286
+ # drives sessions (chi send --wait): a live worker's GET stats, else the
287
+ # snapshot its saved analytics.json rebuilds. Never starts a worker, and
288
+ # never sends the model anything.
289
+ def stats(ids)
290
+ return usage_error("give a session id") if ids.empty?
291
+ return usage_error("stats takes one session id") if ids.size > 1
292
+
293
+ format = @opts[:format]
294
+ unless format.nil? || %w[text json].include?(format)
295
+ return usage_error("unknown format #{format.inspect}: use --format text|json")
296
+ end
297
+
298
+ id = begin
299
+ Samagotchi::Session.resolve_id(ids.first)
300
+ rescue Samagotchi::Session::AmbiguousId => e
301
+ @stdout.flush
302
+ @stderr.puts e.message
303
+ return 1
304
+ end
305
+ session = begin
306
+ Samagotchi::Session.load(id)
307
+ rescue ArgumentError => e
308
+ @stdout.flush
309
+ @stderr.puts e.message
310
+ return 1
311
+ end
312
+
313
+ metrics, live, note = stats_snapshot_for(session)
314
+ if format == "json"
315
+ @stdout.puts JSON.generate({ session_id: session.id, status: session.status, live: live, metrics: metrics })
316
+ else
317
+ @stdout.puts "#{session.id[0, 8]} #{session.status.ljust(7)} #{session.model_name}"
318
+ @stdout.puts TerminalUI::Formatting.format_session_metrics(metrics)
319
+ @stdout.puts note if note
320
+ end
321
+ 0
322
+ end
323
+
324
+ # [snapshot, live, note]: a live worker answers GET stats (no model call);
325
+ # without one — or one that does not answer in time — the snapshot comes
326
+ # from the session's saved analytics.json, rebuilt by SessionMetrics (the
327
+ # same collector a worker runs, loaded from disk). The note says when the
328
+ # worker did not answer. nil when the session has no analytics.json yet.
329
+ # Never starts a worker.
330
+ # @return [Array(Hash, nil, Boolean, String, nil)]
331
+ def stats_snapshot_for(session)
332
+ dir = Samagotchi::Session.session_dir(session.id)
333
+ if (port = Samagotchi::BridgeClient.sidecar_port(dir))
334
+ client = Samagotchi::BridgeClient.new(session_id: session.id, port: port, read_timeout: STATS_TIMEOUT)
335
+ body = client.get_json("stats")&.dig("metrics")
336
+ if body.is_a?(Hash)
337
+ return [TerminalUI::EventRenderer.deep_symbolize_keys(body), true, nil]
338
+ end
339
+ end
340
+ return [nil, false, nil] unless File.file?(File.join(dir, "analytics.json"))
341
+
342
+ snapshot = Samagotchi::SessionMetrics.new.tap { |m| m.session_id = session.id }.saved_snapshot
343
+ note = live_worker?(session.id) ? "(no live worker answered; from the saved analytics.json)" : nil
344
+ [snapshot, false, note]
345
+ end
346
+
347
+ def live_worker?(session_id) = Samagotchi::SessionManager.worker_live?(session_id)
348
+
272
349
  def stop(ids)
273
350
  return usage_error("give session ids") if ids.empty?
274
351
 
@@ -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
 
@@ -1226,7 +1273,7 @@ module Samagotchi
1226
1273
  params: part[:params], status: status,
1227
1274
  description: ToolView.description_title(part[:view] || {}) }
1228
1275
  "#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
1229
- "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
1276
+ "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}"
1230
1277
  end
1231
1278
 
1232
1279
  # The context notes that came since the last prompt (all of them in a
@@ -1320,7 +1367,7 @@ module Samagotchi
1320
1367
 
1321
1368
  activity = EventRenderer.described(event) || {}
1322
1369
  @screen.commit(snapshot_tool_line({ tool: event[:tool], params: activity[:params], status: activity[:status],
1323
- description: activity[:description],
1370
+ description: activity[:description], output_truncated: event[:output_truncated],
1324
1371
  images: event[:images], diff: event[:diff] }))
1325
1372
  end
1326
1373
 
@@ -72,7 +72,8 @@ module Samagotchi
72
72
  # A replayed row (a join) carries its duration (nil: unknown).
73
73
  measured = tool_duration_ms(event)
74
74
  render_streamed_tool_activity(self.class.described(event), duration_ms: event.fetch(:duration_ms, measured),
75
- images: event[:images], diff: event[:diff])
75
+ images: event[:images], diff: event[:diff],
76
+ output_truncated: event[:output_truncated])
76
77
  when :generation_completed, :generation_cancelled, :tool_dispatch_started
77
78
  @view.generation_feedback_finished
78
79
  when :pending_input_merged
@@ -211,13 +212,14 @@ module Samagotchi
211
212
 
212
213
  private
213
214
 
214
- def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil)
215
+ def render_streamed_tool_activity(activity, duration_ms:, images: nil, diff: nil, output_truncated: nil)
215
216
  return if activity.nil?
216
217
 
217
218
  @streamed_tool_activity[tool_activity_key(activity)] += 1
218
219
  line = @view.format_tool_activity_line(activity, duration_ms: duration_ms)
219
220
  line += @view.format_tool_image_suffix(images) if images&.any?
220
221
  line += @view.format_tool_diff_suffix(diff) if diff
222
+ line += @view.format_tool_cut_suffix(output_truncated) if output_truncated
221
223
  @view.print_line(line)
222
224
  end
223
225
 
@@ -14,6 +14,14 @@ module Samagotchi
14
14
  # `tool>` line, elapsed durations. Pure apart from reading whether
15
15
  # $stdout is a colour terminal.
16
16
  module Formatting
17
+ # The /stats text for a metrics snapshot, for the REPL and attached
18
+ # views (an instance includes the module) and for `chi sessions stats`
19
+ # (no UI: the same text from the CLI).
20
+ # @return [String] "(no metrics yet)" without a snapshot
21
+ def self.format_session_metrics(snapshot)
22
+ (@formatter ||= Object.new.extend(Formatting)).send(:format_session_metrics, snapshot)
23
+ end
24
+
17
25
  def format_tool_activity_line(activity, duration_ms: nil)
18
26
  status = activity[:status].to_s
19
27
  elapsed_suffix = duration_ms.nil? ? "" : " (#{format_elapsed_duration(duration_ms)})"
@@ -23,13 +31,17 @@ module Samagotchi
23
31
 
24
32
  # What a tool line says the call did: the model's description of a
25
33
  # command (": List the specs") in place of its cut params, else the
26
- # params, dim.
34
+ # params, dim; then, dim, the name the model called it by when that was
35
+ # an alias's (" (called as bash)").
27
36
  def tool_params_suffix(activity)
28
37
  description = activity[:description].to_s.strip
29
- return ": #{description}" unless description.empty?
30
-
31
38
  params = activity[:params].to_s.strip
32
- 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)}"
33
45
  end
34
46
 
35
47
  # Green ok, yellow stopped (a wait the user's Stop ended), red the rest.
@@ -83,6 +95,45 @@ module Samagotchi
83
95
  paint("↪ cut in for #{STEER_CUT_FOR.fetch(event[:source].to_s, "your message")}", 90)
84
96
  end
85
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
+
86
137
  # "✂ forgot 2 outputs, stubbed 1 stale read · frees ~4.1k tokens (paid
87
138
  # off)": a batch of LLM context edits went in (LLMContextNotice builds
88
139
  # the line; the web prints the same), dim like the retry row.
@@ -138,6 +189,12 @@ module Samagotchi
138
189
  " #{paint("+#{get.call(:added).to_i}", 32)} #{paint("\u2212#{get.call(:removed).to_i}", 31)}"
139
190
  end
140
191
 
192
+ # A row whose output the runner cut (max_tool_output_chars): " [cut]",
193
+ # dim, beside the diff one.
194
+ def format_tool_cut_suffix(output_truncated)
195
+ output_truncated ? " #{paint("[cut]", 90)}" : ""
196
+ end
197
+
141
198
  # Prompt labels by the sender's client_id: a whole id, or a prefix
142
199
  # (ending in ":") it starts with (turn_events.js CLIENT_LABELS;
143
200
  # spec/shared/labels_matrix.json).
@@ -212,7 +269,7 @@ module Samagotchi
212
269
  def snapshot_tool_line(part)
213
270
  status = part[:status].to_s
214
271
  "#{paint("tool>", 36)} #{part[:tool]}#{tool_params_suffix(part)}: #{paint(status, status_color(status))}" \
215
- "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
272
+ "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}"
216
273
  end
217
274
 
218
275
  # A card (Engine#show_card) as a framed block: the title and its
@@ -482,7 +539,8 @@ module Samagotchi
482
539
  end
483
540
 
484
541
  # Render the analytics snapshot as a compact, user-facing report. Raw event
485
- # logs (debug-only) are intentionally excluded; this surface is for the REPL.
542
+ # logs (debug-only) are intentionally excluded; this surface is for the REPL
543
+ # and `chi sessions stats` (the module function above).
486
544
  def format_session_metrics(snapshot)
487
545
  return "(no metrics yet)" unless snapshot.is_a?(Hash)
488
546
 
@@ -501,6 +559,10 @@ module Samagotchi
501
559
  by_tool = snapshot[:tool_calls_by_tool].sort_by { |_k, v| -v }
502
560
  lines << " by tool: #{by_tool.map { |k, v| "#{k}=#{v}" }.join(", ")}"
503
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
504
566
  lines << "iterations: #{snapshot[:iterations_total]}"
505
567
  # Summed over every request: each prompt is sent in full again.
506
568
  lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})" \
@@ -752,6 +752,14 @@ module Samagotchi
752
752
  def run_input_line(session, input)
753
753
  return if input.empty?
754
754
 
755
+ # /cut TEXT and /queue TEXT are deliveries only while a turn runs; at
756
+ # the prompt both run the text as a normal turn, as their usage says.
757
+ if (choice = steer_choice(input))
758
+ return @surface.commit(STEER_CHOICE_USAGE) if choice.last.empty?
759
+
760
+ input = choice.last
761
+ end
762
+
755
763
  # /model, /models, !rollback, !cmd, /continue (shared with workers);
756
764
  # an anytime command's cards print as it shows them (btw's
757
765
  # "thinking…" before the answer).
@@ -1083,6 +1091,10 @@ module Samagotchi
1083
1091
  # plugin's /btw, /model alone) starts now on its own thread; one that
1084
1092
  # runs after the turn (/model X, !cmd) waits in the queue, in arrival
1085
1093
  # order with the other lines that wait; others go back in the prompt.
1094
+ #
1095
+ # A plain line goes in at the running turn's next step boundary and
1096
+ # never cuts; /cut TEXT asks for a cut, /queue TEXT waits for the turn
1097
+ # to end and runs as a turn of its own.
1086
1098
  # @return [Boolean, :back] whether the turn took it, :back to put it back
1087
1099
  def steer_line(line)
1088
1100
  local = local_command(line) unless line.nil?
@@ -1091,6 +1103,12 @@ module Samagotchi
1091
1103
  return detach_note if local == :detach
1092
1104
  return false if @active_cancel_controller&.cancelled?
1093
1105
 
1106
+ # Before every command check: /cut and /queue are the UIs' own words
1107
+ # for a delivery (they are listed for Tab and /help, never run).
1108
+ if (choice = steer_choice(line))
1109
+ return submit_steer_choice(*choice)
1110
+ end
1111
+
1094
1112
  policy = command_registry.mid_turn(line)
1095
1113
  return start_anytime_command(line) if policy == :anytime
1096
1114
  return command_queued(line) if policy == :queue
@@ -1108,12 +1126,37 @@ module Samagotchi
1108
1126
  # Behind a command that waits for the turn's end, a line waits too.
1109
1127
  return wait_for_turn_end(line) if @command_waits
1110
1128
 
1111
- epoch = @engine.input_epoch
1112
1129
  @pending_input_queue.push(line.strip)
1113
- # The user's line cuts a generation that has streamed only thinking
1114
- # for long (steer.cut_after), now or once it has: the model starts the
1115
- # step again with it.
1116
- @engine.cut_for_steer(nil, epoch: epoch)
1130
+ @surface.commit(NEXT_STEP_NOTE)
1131
+ true
1132
+ end
1133
+
1134
+ # /cut TEXT and /queue TEXT during a turn, which the REPL would
1135
+ # otherwise read as prompts (SessionCommands registers them for Tab and
1136
+ # help only). A cut asks the engine to cut the generation only thinking
1137
+ # too long; a queue waits for the turn's end (the REPL's own after-turn
1138
+ # list). Empty text gets the usage line. A line with images never steers
1139
+ # (as a plain one doesn't).
1140
+ # @return [Boolean] whether the turn took it
1141
+ def submit_steer_choice(choice, text)
1142
+ if text.empty?
1143
+ @surface.commit(STEER_CHOICE_USAGE)
1144
+ return true
1145
+ end
1146
+ return image_line_waits(text) if ImageInput.extract(text).any?
1147
+
1148
+ # A /queue waits for the turn's end; a /cut behind a command that
1149
+ # already waits there goes after it too.
1150
+ if choice == :queue || @command_waits
1151
+ @surface.commit(COMMAND_QUEUED)
1152
+ return wait_for_turn_end(text)
1153
+ end
1154
+
1155
+ epoch = @engine.input_epoch
1156
+ @pending_input_queue.push(text)
1157
+ # With no session running the cut is :off; the text still waits for
1158
+ # the running turn's next step.
1159
+ @surface.commit(steer_cut_note(@engine.cut_for_steer(nil, epoch: epoch)))
1117
1160
  true
1118
1161
  end
1119
1162
 
@@ -47,6 +47,9 @@ module Samagotchi
47
47
  }
48
48
  title = tool_title(tool_name, call, cwd: cwd)
49
49
  event[:title] = title if title
50
+ # The name the model used when an alias ran a built-in (called_as: "bash"
51
+ # for an execute), for a UI that shows it.
52
+ event[:called_as] = call[:called_as] if call.is_a?(Hash) && call[:called_as]
50
53
  # The model's own words for a command, which the TUI's tool line
51
54
  # shows in place of the cut params.
52
55
  description = command_description(tool_name, call)
@@ -57,6 +57,8 @@ module Samagotchi
57
57
  # The worker runs in its session's working directory.
58
58
  title = ToolActivity.tool_title(call[:name], call, cwd: Dir.pwd)
59
59
  started[:title] = title if title
60
+ # The name the model called it by when that was an alias's (bash).
61
+ started[:called_as] = call[:called_as] if call[:called_as]
60
62
  # The full command for a richer UI (params stays cut at 80).
61
63
  view = ToolView.for(call[:name], call)&.to_h
62
64
  started[:view] = view if view
@@ -56,6 +56,13 @@ module Samagotchi
56
56
  "forget_outputs" => { content: "note", also: %w[note], verbatim: %w[ids keep restore] }
57
57
  }.freeze
58
58
 
59
+ # A name the model uses for a built-in, mapped to that built-in (a model
60
+ # that keeps calling `bash` with execute's arguments; before this such a
61
+ # call died as an unknown tool). It only takes effect when the
62
+ # arguments really are the target's shape; the call then carries
63
+ # called_as: with the model's own spelling.
64
+ ALIASES = { "bash" => "execute" }.freeze
65
+
59
66
  # One built-in's mapping, resolved from its schema and OVERRIDES.
60
67
  Row = Data.define(:name, :content_keys, :path_key, :fields, :aliases, :verbatim, :fallback, :fallback_key,
61
68
  :options) do
@@ -109,6 +116,14 @@ module Samagotchi
109
116
  def build(name, args, raw: nil)
110
117
  name = name.to_s
111
118
  args = args.is_a?(Hash) ? args.transform_keys(&:to_s) : {}
119
+ # An alias runs its target (bash → execute); the row is the target's,
120
+ # and the call keeps the model's own spelling on called_as:.
121
+ # Not the target's shape (a String main argument, only its keys) and
122
+ # the alias doesn't take: the call passes through as an unknown tool.
123
+ if (target = ALIASES[name.downcase])
124
+ return aliasable?(row(target), args) ? aliased(name, target, args, raw) : passthrough(name, args, raw)
125
+ end
126
+
112
127
  row = row(name)
113
128
  return passthrough(name, args, raw) unless row
114
129
 
@@ -120,6 +135,24 @@ module Samagotchi
120
135
  call
121
136
  end
122
137
 
138
+ # An execute-shaped call the model spelled +name+: built as its +target+,
139
+ # tagged with the model's spelling for the activity event and the log.
140
+ def aliased(name, target, args, raw)
141
+ call = build(target, args, raw: raw)
142
+ call[:called_as] = name
143
+ call
144
+ end
145
+
146
+ # The alias gate: a String main argument, and every key one the target's
147
+ # schema knows (its aliases included). More, or differently shaped, the
148
+ # call is the model's own and passes through as an unknown tool.
149
+ def aliasable?(row, args)
150
+ return false unless row
151
+
152
+ row.content_keys.map { |key| value(row, args, key) }.any?(String) &&
153
+ (args.keys - (row.content_keys + [row.path_key].compact + row.fields + row.aliases.keys)).empty?
154
+ end
155
+
123
156
  def content_value(row, args)
124
157
  found = row.content_keys.lazy.map { |key| value(row, args, key) }.find { |v| !v.nil? }
125
158
  found.nil? ? "" : found.to_s
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.49.0"
4
+ VERSION = "0.51.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1065,8 +1065,12 @@ module Samagotchi
1065
1065
  images = turn_images(id, body["images"])
1066
1066
  return error_response(400, "bad_images", images) if images.is_a?(String)
1067
1067
 
1068
- result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images, state_dir: @state_dir,
1069
- manager: @manager, bridge: -> { live_bridge_client(id) })
1068
+ delivery = body["delivery"] || body[:delivery]
1069
+ return error_response(400, "bad_delivery", "delivery must be a string") unless delivery.nil? || delivery.is_a?(String)
1070
+
1071
+ result = SessionManager.deliver_turn(id, prompt: prompt.to_s, client_id: client_id, images: images,
1072
+ delivery: delivery, state_dir: @state_dir, manager: @manager,
1073
+ bridge: -> { live_bridge_client(id) })
1070
1074
  case result[:status]
1071
1075
  when :accepted
1072
1076
  record_history(body, prompt)
@@ -233,6 +233,9 @@ module Samagotchi
233
233
  part = { tool: name, params: params.to_s }
234
234
  title = ToolActivity.tool_title(name, call, cwd: cwd)
235
235
  part[:title] = title if title
236
+ # The name the model called it by when that was an alias's (the
237
+ # saved call is rebuilt by BuiltinCalls, which sets it again).
238
+ part[:called_as] = call[:called_as] if call[:called_as]
236
239
  # Built from the saved call (never saved itself), so older sessions
237
240
  # get it too.
238
241
  view = ToolView.for(name, call)&.to_h