samagotchi 0.48.0 → 0.50.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 +73 -1
  3. data/bin/chi +2 -2
  4. data/docs/cli.md +12 -1
  5. data/docs/configuration.md +1 -1
  6. data/docs/context.md +9 -2
  7. data/docs/hooks.md +9 -1
  8. data/docs/internals/background-tasks.md +1 -1
  9. data/docs/plugins.md +4 -2
  10. data/docs/sessions.md +5 -2
  11. data/docs/testing.md +2 -1
  12. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/github-pr/plugin.rb +39 -6
  14. data/lib/samagotchi/bundles/loop-guard/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/loop-guard/plugin.rb +13 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/child_move.rb +22 -8
  18. data/lib/samagotchi/engine.rb +3 -2
  19. data/lib/samagotchi/hooks/stream_watch.rb +9 -2
  20. data/lib/samagotchi/host_registry.rb +1 -1
  21. data/lib/samagotchi/model_profile.rb +11 -4
  22. data/lib/samagotchi/plugin/context.rb +13 -1
  23. data/lib/samagotchi/prompt.rb +26 -11
  24. data/lib/samagotchi/prompt_literal_guard.rb +4 -1
  25. data/lib/samagotchi/send_command.rb +61 -0
  26. data/lib/samagotchi/session_commands.rb +1 -2
  27. data/lib/samagotchi/session_metrics.rb +7 -0
  28. data/lib/samagotchi/sessions_command.rb +84 -7
  29. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  30. data/lib/samagotchi/terminal_ui/event_renderer.rb +4 -2
  31. data/lib/samagotchi/terminal_ui/formatting.rb +17 -2
  32. data/lib/samagotchi/terminal_ui/question_prompt.rb +7 -0
  33. data/lib/samagotchi/terminal_ui.rb +1 -1
  34. data/lib/samagotchi/tools/task_runtime.rb +4 -3
  35. data/lib/samagotchi/version.rb +1 -1
  36. data/lib/samagotchi/vision_support.rb +3 -3
  37. data/lib/samagotchi/web/public/app.js +12 -3
  38. data/lib/samagotchi/web/public/live_timing.js +32 -5
  39. data/lib/samagotchi/web/public/stage_model.js +15 -0
  40. data/lib/samagotchi/web/public/stage_view.js +10 -4
  41. data/lib/samagotchi/web/public/timing.js +11 -0
  42. data/lib/samagotchi/web/public/turn_events.js +11 -0
  43. data/lib/samagotchi/web/public/turn_html.js +21 -0
  44. data/lib/samagotchi/web/public/turn_view.js +9 -3
  45. data/lib/samagotchi/web/session_hub.rb +11 -3
  46. metadata +1 -1
@@ -40,7 +40,7 @@ module Samagotchi
40
40
  :system_prefix, :user_prefix, :assistant_prefix,
41
41
  :model_prefix,
42
42
  :stop_sequences, :tool_decl_format,
43
- :image_template
43
+ :image_template, :image_check_token
44
44
 
45
45
  def initialize(config)
46
46
  @name = config[:name]
@@ -65,10 +65,12 @@ module Samagotchi
65
65
  # How the chat template wraps an image's media marker ("%{marker}"),
66
66
  # or nil when chi doesn't know it yet (native images are refused).
67
67
  @image_template = config[:image_template]
68
+ # A token the server's chat template must contain for images to be
69
+ # sent (VisionSupport); defaults to the template's opening token.
70
+ @image_check_token = config[:image_check_token] || image_open_token
68
71
  end
69
72
 
70
- # The token the image template opens with, e.g. "<|vision_start|>": the
71
- # server's chat template must use it too (VisionSupport).
73
+ # The token the image template opens with, e.g. "<|vision_start|>".
72
74
  def image_open_token
73
75
  image_template&.split("%{marker}")&.first.to_s
74
76
  end
@@ -92,7 +94,12 @@ module Samagotchi
92
94
  assistant_prefix: "",
93
95
  model_prefix: "",
94
96
  stop_sequences: ["<turn|>", "<|tool_response>"],
95
- tool_decl_format: :gemma4
97
+ tool_decl_format: :gemma4,
98
+ # The bare marker: llama.cpp's mtmd wraps Gemma 4 images in
99
+ # <|image>…<image|> itself, as its chat path does (V13 spike). The
100
+ # template writes <|image|> for an image part; Gemma 3's doesn't.
101
+ image_template: "%{marker}",
102
+ image_check_token: "<|image|>"
96
103
  )
97
104
  end
98
105
 
@@ -25,9 +25,11 @@ module Samagotchi
25
25
  # model's memory overlay key (ctx.model, ctx.model_key).
26
26
  # +steer+ takes (text, label), +stop_turn+ and +stop_generation+
27
27
  # (reason, label), each true when it acted on a running turn.
28
+ # +frontend+ returns the Engine's interface (:repl, :worker,
29
+ # :non_interactive), which ctx.frontend names for plugins.
28
30
  Host = Struct.new(:session_id, :cwd, :messages, :messages_partial, :notify, :ask_user, :cancelled, :card,
29
31
  :ask_model, :model_name, :model_key, :state_dir, :scratch, :llm_context, :steer, :stop_turn, :stop_generation,
30
- keyword_init: true)
32
+ :frontend, keyword_init: true)
31
33
 
32
34
  # ctx.ask_model failed: the model couldn't be reached, timed out, or
33
35
  # sent nothing usable. The message says why, for the user.
@@ -103,6 +105,16 @@ module Samagotchi
103
105
  # Whether the session is a `chi scratch` one (deleted when it ends).
104
106
  def scratch? = !!@host.scratch&.call
105
107
 
108
+ # ctx.frontend's values, by the Engine's interface.
109
+ FRONTENDS = { repl: :repl, non_interactive: :one_shot, worker: :worker }.freeze
110
+
111
+ # What runs the session, read now (the host sets it after the plugins
112
+ # load): :repl (the REPL, -p without --non-interactive), :one_shot
113
+ # (-p --non-interactive: one turn, then exit) or :worker (a session
114
+ # worker: the web, an attached TUI). A host without one is :worker.
115
+ # @return [Symbol]
116
+ def frontend = FRONTENDS.fetch(@host.frontend&.call, :worker)
117
+
106
118
  # Whether the session is a delegate child (Session#delegate?): a task
107
119
  # another session handed over, not the user's own.
108
120
  def delegate? = !!saved_session&.delegate?
@@ -59,30 +59,45 @@ module Samagotchi
59
59
  [text + prefill_for(messages, profile, prefill), images]
60
60
  end
61
61
 
62
- def self.image_lines(items, profile, images)
63
- return "" if items.empty?
62
+ # A message's image lines: +text+ holds them all in order (what goes
63
+ # inside the message); +placeholders+ and +markers+ split the lines of
64
+ # images not sent from the sent ones' media markers, for Gemma's tool
65
+ # response, whose markers go after the block.
66
+ ImageLines = Data.define(:lines) do
67
+ def text = join(lines)
68
+ def placeholders = join(lines.reject(&:last))
69
+ def markers = join(lines.select(&:last))
70
+
71
+ private
72
+
73
+ def join(subset) = subset.empty? ? "" : "\n#{subset.map(&:first).join("\n")}"
74
+ end
64
75
 
76
+ def self.image_lines(items, profile, images)
65
77
  lines = items.each_with_index.map do |item, index|
66
- next item.placeholder unless item.sent?
67
- next ImageRef.placeholder(item.ref, ImagePlan::CANT_SEE) unless profile.image_template
78
+ next [item.placeholder, false] unless item.sent?
79
+ next [ImageRef.placeholder(item.ref, ImagePlan::CANT_SEE), false] unless profile.image_template
68
80
 
69
81
  images << item.data
70
- "[image #{index + 1}: #{ImageRef.name(item.ref)}] " \
71
- "#{Kernel.format(profile.image_template, marker: ImagePlan::NATIVE_PLACEHOLDER)}"
82
+ ["[image #{index + 1}: #{ImageRef.name(item.ref)}] " \
83
+ "#{Kernel.format(profile.image_template, marker: ImagePlan::NATIVE_PLACEHOLDER)}", true]
72
84
  end
73
- "\n#{lines.join("\n")}"
85
+ ImageLines.new(lines: lines)
74
86
  end
75
87
 
76
88
  def self.format_with_turn_markers(messages, profile, suffixes)
77
89
  last_user = messages.rindex { |m| m[:role] == "user" } || -1
78
90
  parts = messages.each_with_index.map do |m, index|
79
- content = prompt_content_for(m, profile) + suffixes[index]
91
+ lines = suffixes[index]
92
+ content = prompt_content_for(m, profile) + (m[:role] == "tool_response" ? lines.placeholders : lines.text)
80
93
  previous = index.positive? ? messages[index - 1][:role] : nil
81
94
  following = messages[index + 1]&.dig(:role)
82
95
  case m[:role]
83
96
  when "tool_response"
84
- # Inside the model's turn; closed when another role follows.
85
- tool_response_blocks(content, profile) +
97
+ # Inside the model's turn; closed when another role follows. Its
98
+ # sent images go after the blocks, outside the quoted value, as
99
+ # Gemma's chat template puts them; placeholder lines stay inside.
100
+ tool_response_blocks(content, profile) + lines.markers +
86
101
  (following && following != "tool_response" && following != "model" ? "#{profile.turn_end}\n" : "")
87
102
  when "model"
88
103
  content = strip_gemma_thought(content) if index < last_user && !content.include?(profile.tool_call_open)
@@ -136,7 +151,7 @@ module Samagotchi
136
151
  parts = []
137
152
 
138
153
  messages.each_with_index do |m, index|
139
- content = prompt_content_for(m, profile) + suffixes[index]
154
+ content = prompt_content_for(m, profile) + suffixes[index].text
140
155
  case m[:role]
141
156
  when "system"
142
157
  parts << "#{profile.system_prefix}#{content}<|im_end|>\n"
@@ -16,7 +16,10 @@ module Samagotchi
16
16
  ["[[SAMAGOTCHI_LITERAL_GEMMA_STRING_DELIM]]", '<|"|>'],
17
17
  ["[[SAMAGOTCHI_LITERAL_THINK_OPEN]]", "<|think|>"],
18
18
  ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_OPEN]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_OPEN],
19
- ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE]
19
+ ["[[SAMAGOTCHI_LITERAL_THOUGHT_CHANNEL_CLOSE]]", ModelProfile::GEMMA_THOUGHT_CHANNEL_CLOSE],
20
+ ["[[SAMAGOTCHI_LITERAL_IMAGE_SOFT]]", "<|image|>"],
21
+ ["[[SAMAGOTCHI_LITERAL_IMAGE_START]]", "<|image>"],
22
+ ["[[SAMAGOTCHI_LITERAL_IMAGE_END]]", "<image|>"]
20
23
  ].freeze
21
24
 
22
25
  QWEN_TOKEN_PAIRS = [
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "client_id"
4
4
  require_relative "session"
5
+ require_relative "session_commands"
5
6
  require_relative "session_inbox"
6
7
  require_relative "session_manager"
7
8
  require_relative "session_chain"
@@ -175,6 +176,14 @@ module Samagotchi
175
176
  @stderr.puts("chi send: #{e.message}")
176
177
  return 1
177
178
  end
179
+ # A UI-only command (/stats, /exit, …) runs in the terminal and the web,
180
+ # not in the worker: sent through, the worker doesn't run it and the
181
+ # line reaches the model as a prompt, costing a whole turn. Refused
182
+ # before anything is sent (before a --new session is created too); the
183
+ # session's plugin commands are never local.
184
+ if prompt && (local = local_command(prompt))
185
+ return refuse_local_command(local, options)
186
+ end
178
187
  # A file that is not there or not an image: a run-time failure (1),
179
188
  # reported in the --format json line too; --image itself was fine.
180
189
  return CLI::Exit::FAILED unless ingest_images(options[:images])
@@ -440,6 +449,58 @@ module Samagotchi
440
449
  # (a bundle's too).
441
450
  def command_like?(text) = text.to_s.lstrip.start_with?("/", "!")
442
451
 
452
+ # The UI-only built-in command +text+ (stripped) is a line of (/stats,
453
+ # /exit, …), or nil: the worker never runs it, so chi send must not pass
454
+ # it on. What the UIs run: the line as the entry's match takes it — bare
455
+ # "exit" too (/exit's match), but not with arguments ("/exit --delete" is
456
+ # a UI line; "/stats please" is a prompt), as in a terminal.
457
+ # @return [Commands::Registry::Entry, nil]
458
+ def local_command(text)
459
+ line = text.to_s.strip
460
+ return nil unless command_like?(line) || line.match?(/\Aexit\z/i)
461
+
462
+ entry = SessionCommands.builtin_registry.lookup_local(line)
463
+ # Only the exact line the UI runs: "/stats please" is a prompt, and
464
+ # "/exit --delete" is left to the worker (the TUI runs it as /exit, the
465
+ # worker answers its unknown-command way). Bare "exit" is /exit's line
466
+ # as its match takes it; the reply names the command, /exit.
467
+ return nil unless entry
468
+ return entry if line == entry.name
469
+
470
+ line.match?(/\Aexit\z/i) ? entry : nil
471
+ end
472
+
473
+ # A UI-only command: nothing is sent (with --new, no session is created
474
+ # either). /stats points at its model-free reader and names the session;
475
+ # the others just say the worker doesn't run them. Exit 1, as other
476
+ # refusals; with --wait --format json the line is the usual error object
477
+ # (ParentWait's end-of-run line when the reply never comes).
478
+ # @return [Integer] 1
479
+ def refuse_local_command(entry, options)
480
+ if options[:new]
481
+ error_line("chi send: refused: #{local_refusal(entry.name, nil)}")
482
+ elsif options[:wait]
483
+ # --wait: the JSON line goes to stdout, @info is stderr; an unknown
484
+ # or ambiguous id gets resolve's own error line.
485
+ id = resolve(options[:ids].first) or return CLI::Exit::FAILED
486
+ @stderr.puts("#{id[0, 8]} refused: #{local_refusal(entry.name, id)}")
487
+ else
488
+ options[:ids].uniq.each do |given|
489
+ id = resolve(given) or next
490
+ @info.puts("#{id[0, 8]} refused: #{local_refusal(entry.name, id)}")
491
+ end
492
+ end
493
+ CLI::Exit::FAILED
494
+ end
495
+
496
+ # Why a UI-only command isn't sent: /stats points at its model-free
497
+ # reader (naming the session when there is one).
498
+ def local_refusal(name, id)
499
+ return "#{name} is a terminal and web command; chi send doesn't run it" unless name == "/stats"
500
+
501
+ "#{name} is a terminal and web command; use chi sessions stats #{id ? id[0, 8] : "ID"}"
502
+ end
503
+
443
504
  # --wait after a message that ran as a session command: no reply comes
444
505
  # for it. Its output shows where the session is open.
445
506
  # @return [Integer] 0
@@ -71,8 +71,7 @@ module Samagotchi
71
71
  continue_answer(answer.empty? ? CONTINUE_COMMAND : answer)
72
72
  end
73
73
  # It reads the hosts only (the registry is mutex-guarded): mid-turn too.
74
- registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)",
75
- mid_turn: ->(_text) { :anytime }) do |text|
74
+ registry.register(MODELS_COMMAND, "list the hosts' models (/models <text> filters)", anytime: true) do |text|
76
75
  reply(models_listing(text.delete_prefix(MODELS_COMMAND).strip))
77
76
  end
78
77
  registry.register(GUARDRAILS_COMMAND, "list the guardrail rules and approvals (/guardrails revoke N)",
@@ -141,6 +141,9 @@ module Samagotchi
141
141
  :cuts,
142
142
  :capped,
143
143
  :model,
144
+ # The turn the user started by answering the step-limit question with
145
+ # Continue (:turn_started's +continue+); its record carries it.
146
+ :continue,
144
147
  :id,
145
148
  :started_at,
146
149
  :started_monotonic,
@@ -539,6 +542,7 @@ module Samagotchi
539
542
  cuts: 0,
540
543
  capped: 0,
541
544
  model: nil,
545
+ continue: event[:continue] ? true : false,
542
546
  id: event[:turn_id] || SecureRandom.uuid,
543
547
  started_at: now.iso8601(3),
544
548
  started_monotonic: monotonic_time,
@@ -575,6 +579,9 @@ module Samagotchi
575
579
  record[:cancellation_reason] = reason.to_s unless reason.nil? || reason.to_s.empty?
576
580
  record[:cancelled_by] = by.to_s unless by.nil? || by.to_s.empty?
577
581
  record[:failure] = failure if failure
582
+ # Every record says, so the report can tell a file with no Continue
583
+ # from one written before the mark.
584
+ record[:continue] = @turn.continue
578
585
  record.merge!(turn_token_fields(@turn))
579
586
  @turn_records << record
580
587
  count_turn(record)
@@ -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 }.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
 
@@ -1226,7 +1226,7 @@ module Samagotchi
1226
1226
  params: part[:params], status: status,
1227
1227
  description: ToolView.description_title(part[:view] || {}) }
1228
1228
  "#{format_tool_activity_line(activity, duration_ms: duration_ms)}" \
1229
- "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}"
1229
+ "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}"
1230
1230
  end
1231
1231
 
1232
1232
  # The context notes that came since the last prompt (all of them in a
@@ -1320,7 +1320,7 @@ module Samagotchi
1320
1320
 
1321
1321
  activity = EventRenderer.described(event) || {}
1322
1322
  @screen.commit(snapshot_tool_line({ tool: event[:tool], params: activity[:params], status: activity[:status],
1323
- description: activity[:description],
1323
+ description: activity[:description], output_truncated: event[:output_truncated],
1324
1324
  images: event[:images], diff: event[:diff] }))
1325
1325
  end
1326
1326
 
@@ -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)})"
@@ -138,6 +146,12 @@ module Samagotchi
138
146
  " #{paint("+#{get.call(:added).to_i}", 32)} #{paint("\u2212#{get.call(:removed).to_i}", 31)}"
139
147
  end
140
148
 
149
+ # A row whose output the runner cut (max_tool_output_chars): " [cut]",
150
+ # dim, beside the diff one.
151
+ def format_tool_cut_suffix(output_truncated)
152
+ output_truncated ? " #{paint("[cut]", 90)}" : ""
153
+ end
154
+
141
155
  # Prompt labels by the sender's client_id: a whole id, or a prefix
142
156
  # (ending in ":") it starts with (turn_events.js CLIENT_LABELS;
143
157
  # spec/shared/labels_matrix.json).
@@ -212,7 +226,7 @@ module Samagotchi
212
226
  def snapshot_tool_line(part)
213
227
  status = part[:status].to_s
214
228
  "#{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])}"
229
+ "#{format_tool_image_suffix(part[:images])}#{format_tool_diff_suffix(part[:diff])}#{format_tool_cut_suffix(part[:output_truncated])}"
216
230
  end
217
231
 
218
232
  # A card (Engine#show_card) as a framed block: the title and its
@@ -482,7 +496,8 @@ module Samagotchi
482
496
  end
483
497
 
484
498
  # 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.
499
+ # logs (debug-only) are intentionally excluded; this surface is for the REPL
500
+ # and `chi sessions stats` (the module function above).
486
501
  def format_session_metrics(snapshot)
487
502
  return "(no metrics yet)" unless snapshot.is_a?(Hash)
488
503
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "question_slot"
4
4
  require_relative "../turn_flow"
5
+ require_relative "../question_desk"
5
6
 
6
7
  module Samagotchi
7
8
  class TerminalUI
@@ -80,9 +81,15 @@ module Samagotchi
80
81
  " waiting in parent #{@relayed_to} too (answering here works)" if @relayed_to
81
82
  end
82
83
 
84
+ # The line a question dismissed in a --non-interactive run leaves (no
85
+ # one could answer it there): the REPL's own, and an attached UI's on
86
+ # its cancel (QuestionDesk::UNANSWERABLE_REASON).
87
+ UNANSWERABLE_TEXT = "(dismissed: no one to answer in a non-interactive run)"
88
+
83
89
  # What a question closed with no answer here says: a relayed approval
84
90
  # names where it went; else "(question cancelled)".
85
91
  def closed_text(reason)
92
+ return UNANSWERABLE_TEXT if reason.to_s == QuestionDesk::UNANSWERABLE_REASON
86
93
  return CONTINUE_CLOSED.fetch(reason.to_s, "(question cancelled)") if continue?
87
94
  return "(question cancelled)" unless @delegate
88
95
 
@@ -1356,7 +1356,7 @@ module Samagotchi
1356
1356
  # dismissed it.
1357
1357
  def dismiss_unaskable_question(prompt)
1358
1358
  @engine.cancel_question(QuestionDesk::UNANSWERABLE_REASON) rescue nil
1359
- banner(prompt.summary("(dismissed: no one to answer in a non-interactive run)", paint: method(:paint)))
1359
+ banner(prompt.summary(QuestionPrompt::UNANSWERABLE_TEXT, paint: method(:paint)))
1360
1360
  false
1361
1361
  end
1362
1362
 
@@ -213,7 +213,7 @@ module Samagotchi
213
213
  return record unless record["status"] == "running"
214
214
 
215
215
  pid = record["pid"]
216
- return record if chis_group?(record)
216
+ return record if chis_group?(record, started: record["pid_started"])
217
217
  # Gone: its exit code says how it ended.
218
218
  return mark_finished_without_exit_code(record) if ProcessGroup.signalable?(pid) && !ProcessGroup.alive?(pid)
219
219
 
@@ -222,8 +222,9 @@ module Samagotchi
222
222
 
223
223
  # Whether the record's pid still leads its own group. With +started+
224
224
  # (the start time recorded at task_create), also the same process: a
225
- # ps per call, so only task_stop asks it, just before the signal;
226
- # a refresh (each task_wait poll, task_list) checks the group only.
225
+ # ps per call on macOS (~5 ms), asked by each refresh (task_list,
226
+ # task_get, each task_wait poll) so a reused pid reads as exited, and
227
+ # by task_stop again just before the signal.
227
228
  def chis_group?(record, started: nil) = ProcessGroup.leader?(record["pid"], started: started)
228
229
 
229
230
  # A running record whose pid isn't a group chi started (NOT_CHIS_PROCESS)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.48.0"
4
+ VERSION = "0.50.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -73,9 +73,9 @@ module Samagotchi
73
73
  return no("the server gives no media marker (update llama.cpp)") unless media_marker(props)
74
74
  return no("profile #{profile&.name || "?"} has no image template yet") unless profile&.image_template
75
75
 
76
- open = profile.image_open_token
77
- unless open.empty? || body["chat_template"].to_s.include?(open)
78
- return no("the server's chat template doesn't use #{open} (profile #{profile.name})")
76
+ check = profile.image_check_token
77
+ unless check.empty? || body["chat_template"].to_s.include?(check)
78
+ return no("the server's chat template doesn't use #{check} (profile #{profile.name})")
79
79
  end
80
80
 
81
81
  Answer.new(value: true, reason: nil)
@@ -62,7 +62,7 @@ import {
62
62
  } from "./timing.js";
63
63
  import { createLiveTurn } from "./live_turn.js";
64
64
  import { DONE_MS, applyInitEvent, dropDone, initRowHtml, newInitTasks, seedInitTasks } from "./init_row.js";
65
- import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
65
+ import { clientLabel, commandView, isReportClient, displayWait, dropEarlyRestores, emptyRetryLine, historyHasCommandBubble, hookNoticeLabel, initWaitLine, isOwn, llmContextHover, llmContextLine, sessionCommandLine, keepEarlyRestore, newClientId, noticeLine, promptOps, reminderText, restoreAction, composerAfterAck, restoreOnAck, replaysDrawnTurn, retryStatusLine, snapshotEvents, steerCutLine, startPageReplyOrHint, turnOutput, unknownCommandHint, webLocalReply, workerGoneText } from "./turn_events.js";
66
66
 
67
67
  const $ = (s) => document.querySelector(s);
68
68
  const topStripEl = $("#topStrip");
@@ -2265,10 +2265,19 @@ const streamHandlers = {
2265
2265
  if (view.resync) await resync();
2266
2266
  // A card's action that isn't anytime (the step-limit question's
2267
2267
  // /continue) has no bubble: nothing to remove. An anytime or queued
2268
- // one's bubble was drawn at its command_queued: filled in here.
2268
+ // one's bubble was drawn at its command_queued: filled in here. One
2269
+ // queued for after the turn ran after it: its bubble moves from among
2270
+ // the turn's rows to after its answer and timing line.
2269
2271
  const queued = view.anytime || view.queued ? commandBubble(view.commandId) : null;
2270
2272
  if (view.hidden) queued?.remove();
2271
- else if (queued) drawCommandBubble(queued, view);
2273
+ else if (queued && view.queued && !view.anytime) {
2274
+ queued.remove();
2275
+ addCommandBubble(view);
2276
+ } else if (queued) drawCommandBubble(queued, view);
2277
+ // A command that changed the conversation (!cmd): the redrawn history
2278
+ // ends with its command bubble already (the saved "!(…)\n<output>"
2279
+ // message renders as one) — no second bubble, or the output shows twice.
2280
+ else if (view.resync && historyHasCommandBubble(findInTurnRoots(".bubble.command"), view.line)) { /* drawn by the resync */ }
2272
2281
  else addCommandBubble(view);
2273
2282
  },
2274
2283
  // An anytime command (/help, a plugin's /btw) runs at once, beside a