samagotchi 0.76.0 → 0.77.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -1
  3. data/docs/internals/engine.md +1 -1
  4. data/docs/plugins.md +42 -0
  5. data/lib/samagotchi/answer_command.rb +5 -5
  6. data/lib/samagotchi/bridge.rb +3 -3
  7. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
  8. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  9. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  10. data/lib/samagotchi/child_reports.rb +1 -1
  11. data/lib/samagotchi/engine.rb +21 -12
  12. data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
  13. data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
  14. data/lib/samagotchi/memory_bundle/builder.rb +2 -0
  15. data/lib/samagotchi/message_parts.rb +272 -0
  16. data/lib/samagotchi/parent_report.rb +30 -22
  17. data/lib/samagotchi/pending_question.rb +49 -0
  18. data/lib/samagotchi/question_desk.rb +39 -32
  19. data/lib/samagotchi/relay.rb +8 -9
  20. data/lib/samagotchi/relay_verifier.rb +2 -2
  21. data/lib/samagotchi/relay_watcher.rb +2 -2
  22. data/lib/samagotchi/reply_wait.rb +4 -4
  23. data/lib/samagotchi/row_refs.rb +1 -1
  24. data/lib/samagotchi/session.rb +22 -14
  25. data/lib/samagotchi/session_commands.rb +4 -1
  26. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  27. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  28. data/lib/samagotchi/terminal_ui/formatting.rb +3 -106
  29. data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
  30. data/lib/samagotchi/terminal_ui.rb +5 -3
  31. data/lib/samagotchi/tool_output_cut.rb +1 -1
  32. data/lib/samagotchi/tool_response.rb +13 -13
  33. data/lib/samagotchi/tool_row_fields.rb +1 -1
  34. data/lib/samagotchi/tool_runner.rb +16 -9
  35. data/lib/samagotchi/tools/delegate_relay.rb +7 -7
  36. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  37. data/lib/samagotchi/tools/web_fetch.rb +3 -1
  38. data/lib/samagotchi/version.rb +1 -1
  39. data/lib/samagotchi/web/app.rb +2 -2
  40. data/lib/samagotchi/web/public/app.js +17 -55
  41. data/lib/samagotchi/web/public/ctx.js +12 -1
  42. data/lib/samagotchi/web/public/index.html +2 -1
  43. data/lib/samagotchi/web/public/markdown_place.js +53 -0
  44. data/lib/samagotchi/web/public/stream_handlers.js +7 -3
  45. data/lib/samagotchi/web/public/toasts.js +69 -0
  46. metadata +6 -2
  47. data/lib/samagotchi/web/message_parts.rb +0 -274
@@ -70,9 +70,9 @@ module Samagotchi
70
70
  # of range; [] for a freeform-only deny.
71
71
  def labels(indices, question_id)
72
72
  pending = @engine.pending_question
73
- return [] unless pending && pending[:id].to_s == question_id
73
+ return [] unless pending && pending.id.to_s == question_id
74
74
 
75
- options = Array(pending[:options])
75
+ options = Array(pending.options)
76
76
  indices = Array(indices)
77
77
  return nil unless indices.all? { |index| index.is_a?(Integer) && index >= 0 && index < options.size }
78
78
 
@@ -37,8 +37,8 @@ module Samagotchi
37
37
  end
38
38
 
39
39
  def relayed?(pending, question_id, relay_id)
40
- relay = pending && pending[:relayed_to]
41
- return false unless relay.is_a?(Hash) && pending[:id].to_s == question_id.to_s
40
+ relay = pending&.relayed_to
41
+ return false unless relay.is_a?(Hash) && pending.id.to_s == question_id.to_s
42
42
 
43
43
  (relay[:relay_id] || relay["relay_id"]).to_s == relay_id.to_s
44
44
  end
@@ -30,7 +30,7 @@ module Samagotchi
30
30
  # @!attribute text [String, nil] the reply (:done) or the worker's error
31
31
  # (:error)
32
32
  # @!attribute file [String, nil] the reply's filename, the next cursor
33
- # @!attribute question [Hash, nil] the pending question
33
+ # @!attribute question [PendingQuestion, nil] the pending question
34
34
  # (:waiting_for_answer)
35
35
  # @!attribute outcome [String, nil] how the turn ended when it left no
36
36
  # reply (:no_reply): "failed", "canceled", "completed" (empty),
@@ -152,7 +152,7 @@ module Samagotchi
152
152
  # lists' rule): one a dead worker left waits for no one, and a chi
153
153
  # REPL's is answered there.
154
154
  pending = session.pending_question
155
- if pending && !(baseline && pending[:id] == baseline[:question_id]) &&
155
+ if pending && !(baseline && pending.id == baseline[:question_id]) &&
156
156
  (turn.nil? || own_question?(session, turn, claimed)) &&
157
157
  session.waiting_question(live: SessionManager.worker_live?(id, state_dir: state_dir))
158
158
  return Result.new(status: :waiting_for_answer, question: pending, session: session)
@@ -217,7 +217,7 @@ module Samagotchi
217
217
  # its messages, the question pending then, and when its last turn
218
218
  # ended, so a turn that ends before the first look still ends the wait.
219
219
  # @return [Hash]
220
- def baseline_of(session, question_id: session.pending_question&.dig(:id))
220
+ def baseline_of(session, question_id: session.pending_question&.id)
221
221
  { messages: session.messages.size, question_id: question_id, last_turn: session.last_turn&.ended_at }
222
222
  end
223
223
 
@@ -252,7 +252,7 @@ module Samagotchi
252
252
  end
253
253
 
254
254
  pending = session.pending_question
255
- return nil unless pending && pending[:kind].to_s == "continue"
255
+ return nil unless pending && pending.kind.to_s == "continue"
256
256
  return nil unless turn_ids?(session_dir)
257
257
 
258
258
  session.last_turn&.enqueued_id
@@ -10,7 +10,7 @@ module Samagotchi
10
10
  # whose +to_h+ the row carries as +refs+ on tool_call_started and
11
11
  # tool_call_completed (ToolRowFields::EVENT_KEYS), saved with the result
12
12
  # as tool_refs (ToolResponse::SAVED_KEYS) for a reload, which has no
13
- # plugins (Web::MessageParts). Display only: the model never sees them.
13
+ # plugins (MessageParts). Display only: the model never sees them.
14
14
  #
15
15
  # A file ref is any file under the session's folder (plan D7): a relative
16
16
  # path is resolved against it, a path outside it is dropped. The folder
@@ -8,6 +8,7 @@ require_relative "atomic_file"
8
8
  require_relative "context_note"
9
9
  require_relative "interrupted_turn"
10
10
  require_relative "last_turn"
11
+ require_relative "pending_question"
11
12
  require_relative "llm_context_override"
12
13
  require_relative "session_setup"
13
14
  require_relative "prompt_note"
@@ -49,7 +50,7 @@ module Samagotchi
49
50
 
50
51
  attr_accessor :id, :metadata_version, :mode, :model_name, :working_directory, :messages,
51
52
  :created_at, :updated_at, :status, :last_prompt, :first_preview, :test_run,
52
- :pending_question, :used_memory_names
53
+ :used_memory_names
53
54
  # Memories the session was started with (--memory) and memories hidden
54
55
  # from it (--mute): the worker rebuilds the same prompt on a respawn.
55
56
  # Names as given; the engine normalizes them.
@@ -98,6 +99,15 @@ module Samagotchi
98
99
  # A `chi scratch` session: deleted when its REPL ends, and by the next
99
100
  # sweep (or `chi sessions clean`) when the process died first.
100
101
  attr_accessor :scratch
102
+ # The question this session waits on (PendingQuestion), saved as
103
+ # session.json's "pending_question"; nil with none. A Hash (a saved
104
+ # record, string or symbol keys) is read with PendingQuestion.from_file.
105
+ attr_reader :pending_question
106
+
107
+ def pending_question=(value)
108
+ @pending_question = PendingQuestion.from_file(value)
109
+ end
110
+
101
111
  # How the last turn ended (LastTurn), for a parent's wait (ReplyWait)
102
112
  # and the web's notifications (the hub sends it in the summary); nil
103
113
  # before the first turn.
@@ -246,7 +256,7 @@ module Samagotchi
246
256
  @last_prompt = last_prompt
247
257
  @first_preview = first_preview
248
258
  @test_run = !!test_run
249
- @pending_question = pending_question
259
+ self.pending_question = pending_question
250
260
  @used_memory_names = self.class.name_list(used_memory_names)
251
261
  @preloaded_memory_names = self.class.name_list(preloaded_memory_names)
252
262
  @muted_memory_names = self.class.name_list(muted_memory_names)
@@ -289,12 +299,12 @@ module Samagotchi
289
299
  # @return [Hash, nil]
290
300
  def waiting_question(live:)
291
301
  pending = pending_question
292
- return nil unless live && pending.is_a?(Hash) && pending[:id]
302
+ return nil unless live && pending&.id
293
303
 
294
- kind = pending[:kind].to_s
295
- relayed = pending[:relayed_to]
304
+ kind = pending.kind.to_s
305
+ relayed = pending.relayed_to
296
306
  relayed_to = relayed.is_a?(Hash) ? (relayed[:parent_short] || relayed["parent_short"]) : nil
297
- { id: pending[:id], kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
307
+ { id: pending.id, kind: %w[approval hook continue].include?(kind) ? kind : "question", relayed_to: relayed_to }.compact
298
308
  end
299
309
 
300
310
  # A question saved by a process that no longer runs this session (the
@@ -306,7 +316,7 @@ module Samagotchi
306
316
  return nil unless pending
307
317
 
308
318
  @pending_question = nil
309
- pending[:id].to_s
319
+ pending.id.to_s
310
320
  end
311
321
 
312
322
  # The stored project root, else (a file from before the field) the
@@ -390,10 +400,10 @@ module Samagotchi
390
400
 
391
401
  # The fields of <id>.json, in the order #save writes them: key => the
392
402
  # value for a file without it (REQUIRED for the ones it must have).
393
- # .from_h and #to_h both go by this table; messages and
394
- # pending_question change key type on the way (symbols in memory,
395
- # strings on disk); last_turn and prompt_notes are value objects in
396
- # memory (LastTurn, PromptNote).
403
+ # .from_h and #to_h both go by this table; messages change key type on
404
+ # the way (symbols in memory, strings on disk); last_turn, prompt_notes
405
+ # and pending_question are value objects in memory (LastTurn,
406
+ # PromptNote, PendingQuestion).
397
407
  FIELDS = {
398
408
  "metadata_version" => 1,
399
409
  "id" => REQUIRED,
@@ -435,8 +445,6 @@ module Samagotchi
435
445
  [key.to_sym, default.equal?(REQUIRED) ? data.fetch(key) : data.fetch(key, default)]
436
446
  end
437
447
  attrs[:messages] = messages ? Array(attrs[:messages]).map { |msg| symbolize_message_keys(msg) } : []
438
- pending = attrs[:pending_question]
439
- attrs[:pending_question] = pending.is_a?(Hash) ? symbolize_message_keys(pending) : nil
440
448
  new(**attrs)
441
449
  end
442
450
 
@@ -557,7 +565,7 @@ module Samagotchi
557
565
  when "messages"
558
566
  (checkpoint ? checkpoint[:messages] : @messages).map { |msg| scrub_utf8(stringify_message_keys(msg)) }
559
567
  when "interrupted_turn" then (checkpoint ? checkpoint[:marker] : @interrupted_turn)&.to_file
560
- when "pending_question" then @pending_question && scrub_utf8(stringify_message_keys(@pending_question))
568
+ when "pending_question" then @pending_question && scrub_utf8(@pending_question.to_file)
561
569
  when "test_run" then !!@test_run
562
570
  when "delegate" then @delegate
563
571
  when "llm_context" then llm_context_file
@@ -592,8 +592,11 @@ module Samagotchi
592
592
  @engine.switch_model!(name, persist_default: persist_default)
593
593
  @default_model = @engine.default_model_name if persist_default
594
594
  persist_session_model
595
- # The ctx % on the new model's window, before its first turn.
595
+ # The ctx % on the new model's window, before its first turn; saved
596
+ # too, so the session's card (the hub's summary reads analytics.json)
597
+ # stops showing the old model's % before a turn ends.
596
598
  @engine.announce_switched_context!
599
+ @engine.metrics.persist(state_dir: @engine.session_state_dir)
597
600
  end
598
601
 
599
602
  def persist_session_model
@@ -88,9 +88,10 @@ module Samagotchi
88
88
  # A question left waiting (exit 3), in full on stderr as `chi send
89
89
  # --wait` prints it (ParentReport), so a script or a parent agent
90
90
  # can answer it with chi answer.
91
- # @param pending [Hash] the pending question (symbol keys)
91
+ # @param pending [Hash] the pending question as the Bridge sent it
92
92
  def report_unanswered(pending, session_id:, err: $stderr)
93
- err.print("chi: #{ParentReport.question_text(pending, session_id: session_id)}")
93
+ question = PendingQuestion.from_file(pending)
94
+ err.print("chi: #{ParentReport.question_text(question, session_id: session_id)}")
94
95
  err.flush
95
96
  end
96
97
 
@@ -28,7 +28,7 @@ require_relative "../children_status"
28
28
  require_relative "../guardrails/parent_approvals"
29
29
  require_relative "../tool_activity"
30
30
  require_relative "../tool_view"
31
- require_relative "../web/message_parts"
31
+ require_relative "../message_parts"
32
32
 
33
33
  module Samagotchi
34
34
  class TerminalUI
@@ -1274,7 +1274,7 @@ module Samagotchi
1274
1274
  @screen.commit(format_steer_line(source: m[:source], text: m[:content]))
1275
1275
  elsif %w[model assistant].include?(m[:role].to_s)
1276
1276
  responses = messages.drop(i + 1).take_while { |r| r[:role].to_s == "tool_response" }
1277
- Array(Web::MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
1277
+ Array(MessageParts.for_message(m, responses)&.dig(:tools)).each do |part|
1278
1278
  record = join_record(records, part)
1279
1279
  duration = record&.dig("duration_ms")
1280
1280
  @screen.commit(join_tool_line(part, duration_ms: duration.is_a?(Numeric) ? duration : nil,
@@ -9,6 +9,7 @@ require_relative "../steer_cut"
9
9
  require_relative "../memory_bundle/index_size"
10
10
  require_relative "../prompt_note"
11
11
  require_relative "../prompt_files"
12
+ require_relative "model_segments_text"
12
13
 
13
14
  module Samagotchi
14
15
  class TerminalUI
@@ -16,6 +17,8 @@ module Samagotchi
16
17
  # `tool>` line, elapsed durations. Pure apart from reading whether
17
18
  # $stdout is a colour terminal.
18
19
  module Formatting
20
+ include ModelSegmentsText
21
+
19
22
  # The /stats text for a metrics snapshot, for the REPL and attached
20
23
  # views (an instance includes the module) and for `chi sessions stats`
21
24
  # (no UI: the same text from the CLI).
@@ -451,45 +454,6 @@ module Samagotchi
451
454
  "#{reported.empty? ? estimate : "#{reported} reported + #{estimate}"} (this session only)"
452
455
  end
453
456
 
454
- # ── A session's rows per model (the snapshot's model_segments) ──────
455
- # The words the web's ctx tooltip shares (ctx.js; spec/shared/
456
- # labels_matrix.json model_segments). Rows come symbol- or
457
- # string-keyed (the attached TUI's as JSON).
458
-
459
- # A row's model: its ref; the rows folded past the cap ("3 earlier
460
- # segments", told by folded_segments); a session's turns from before
461
- # turns named their model ("earlier turns").
462
- def model_segment_label(row)
463
- folded = segment_value(row, :folded_segments).to_i
464
- return "#{folded} earlier segments" if folded.positive?
465
-
466
- ref = segment_value(row, :model_ref).to_s
467
- ref.empty? ? "earlier turns" : ref
468
- end
469
-
470
- # "6.0k in / 40 out": the row's prompt and completion sums.
471
- def model_segment_tokens_text(row)
472
- count = ->(key) { MemoryBundle::IndexSize.count_text(segment_value(row, key).to_i) }
473
- "#{count.call(:prompt_sum)} in / #{count.call(:completion_sum)} out"
474
- end
475
-
476
- # "$0.42", "~$0.12", "$0.42 + ~$0.12" (reported + estimated); "" for none.
477
- def model_segment_cost_text(row)
478
- [cost_text(segment_value(row, :cost_sum)), cost_text(segment_value(row, :cost_estimate_sum), estimate: true)]
479
- .reject(&:empty?).join(" + ")
480
- end
481
-
482
- # "switch prefill 3.0k uncached prompt ≈$0.0060": what the switch to the
483
- # row's model prefilled (its cost an estimate from the configured
484
- # price, left out without one); "" for none.
485
- def model_segment_prefill_text(row)
486
- tokens = segment_value(row, :prefill_tokens).to_i
487
- return "" unless tokens.positive?
488
-
489
- cost = cost_text(segment_value(row, :prefill_cost_estimate))
490
- "switch prefill #{MemoryBundle::IndexSize.count_text(tokens)} uncached prompt#{" ≈#{cost}" unless cost.empty?}"
491
- end
492
-
493
457
  private
494
458
 
495
459
  # The recap shown on return: dim, one "recap>" block, noting how many
@@ -745,73 +709,6 @@ module Samagotchi
745
709
  text
746
710
  end
747
711
 
748
- def segment_value(row, key)
749
- row[key] || row[key.to_s]
750
- end
751
-
752
- # /stats' models: block, in place of the tokens line: a row per
753
- # segment (model, when, turns, tokens, its average speed and cost), a
754
- # switch's prefill under the row of the model switched to, and the
755
- # total (the tokens line's sums and breakdown).
756
- def models_block_lines(segments, turns, tokens, token_src_label)
757
- dated = segment_dates_differ?(segments)
758
- cells = segments.map do |row|
759
- speed = speed_text(segment_value(row, :avg_decode_tps), segment_value(row, :tps_source))
760
- [model_segment_label(row), segment_span_text(row, dated: dated), turns_text(segment_value(row, :turns)),
761
- [model_segment_tokens_text(row), speed, model_segment_cost_text(row)].reject(&:empty?).join(" ")]
762
- end
763
- total = ["total", "", turns_text(turns),
764
- "#{MemoryBundle::IndexSize.count_text(tokens[:prompt_sum].to_i)} in / " \
765
- "#{MemoryBundle::IndexSize.count_text(tokens[:completion_sum].to_i)} out " \
766
- "(all requests, #{token_src_label}#{token_breakdown_text(tokens)})"]
767
- widths = (0..2).map { |i| (cells + [total]).map { |cell| cell[i].length }.max }
768
- row_text = ->(cell) { cell.each_with_index.map { |text, i| i < 3 ? text.ljust(widths[i]) : text }.join(" ") }
769
- lines = []
770
- segments.each_with_index do |row, i|
771
- lines << "#{i.zero? ? "models:".ljust(18) : " " * 18}#{row_text.call(cells[i])}"
772
- prefill = model_segment_prefill_text(row)
773
- lines << "#{" " * 20}└ #{prefill}" unless prefill.empty?
774
- end
775
- lines << "#{" " * 18}#{row_text.call(total)}"
776
- end
777
-
778
- def turns_text(count)
779
- "#{count.to_i} #{count.to_i == 1 ? "turn" : "turns"}"
780
- end
781
-
782
- # "10:02–10:41", "10:43–now" (a row with a turn running); with the
783
- # day ("Oct 16 10:02") when the rows span more than one day.
784
- def segment_span_text(row, dated:)
785
- started = segment_time(segment_value(row, :started_at))
786
- ended = segment_time(segment_value(row, :ended_at))
787
- format_time = lambda { |time|
788
- if time
789
- time.strftime(dated ? "%b %-d %H:%M" : "%H:%M")
790
- else
791
- "?"
792
- end
793
- }
794
- "#{format_time.call(started)}–#{ended ? format_time.call(ended) : "now"}"
795
- end
796
-
797
- def segment_dates_differ?(segments)
798
- days = segments.flat_map { |row| [segment_value(row, :started_at), segment_value(row, :ended_at)] }
799
- .filter_map { |stamp| segment_time(stamp)&.strftime("%F") }
800
- days.uniq.size > 1
801
- end
802
-
803
- def segment_time(stamp)
804
- stamp ? Time.iso8601(stamp.to_s).localtime : nil
805
- rescue ArgumentError
806
- nil
807
- end
808
-
809
- # The row the session's last speed came from: the newest with one.
810
- def last_speed_label(segments)
811
- row = segments.reverse_each.find { |segment| segment_value(segment, :last_decode_tps) }
812
- row && model_segment_label(row)
813
- end
814
-
815
712
  def format_elapsed_duration(duration_ms)
816
713
  duration_ms = duration_ms.to_f
817
714
  return "" if duration_ms.negative?
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../memory_bundle/index_size"
5
+
6
+ module Samagotchi
7
+ class TerminalUI
8
+ # A session's rows per model in /stats (the snapshot's model_segments).
9
+ # The four public model_segment_* labels are the words the web's ctx
10
+ # tooltip shares (ctx.js; spec/shared/labels_matrix.json model_segments).
11
+ # Rows come symbol- or string-keyed (the attached TUI's as JSON).
12
+ # Included by Formatting, whose cost_text and speed_text it uses.
13
+ module ModelSegmentsText
14
+ # A row's model: its ref; the rows folded past the cap ("3 earlier
15
+ # segments", told by folded_segments); a session's turns from before
16
+ # turns named their model ("earlier turns").
17
+ def model_segment_label(row)
18
+ folded = segment_value(row, :folded_segments).to_i
19
+ return "#{folded} earlier segments" if folded.positive?
20
+
21
+ ref = segment_value(row, :model_ref).to_s
22
+ ref.empty? ? "earlier turns" : ref
23
+ end
24
+
25
+ # "6.0k in / 40 out": the row's prompt and completion sums.
26
+ def model_segment_tokens_text(row)
27
+ count = ->(key) { MemoryBundle::IndexSize.count_text(segment_value(row, key).to_i) }
28
+ "#{count.call(:prompt_sum)} in / #{count.call(:completion_sum)} out"
29
+ end
30
+
31
+ # "$0.42", "~$0.12", "$0.42 + ~$0.12" (reported + estimated); "" for none.
32
+ def model_segment_cost_text(row)
33
+ [cost_text(segment_value(row, :cost_sum)), cost_text(segment_value(row, :cost_estimate_sum), estimate: true)]
34
+ .reject(&:empty?).join(" + ")
35
+ end
36
+
37
+ # "switch prefill 3.0k uncached prompt ≈$0.0060": what the switch to the
38
+ # row's model prefilled (its cost an estimate from the configured
39
+ # price, left out without one); "" for none.
40
+ def model_segment_prefill_text(row)
41
+ tokens = segment_value(row, :prefill_tokens).to_i
42
+ return "" unless tokens.positive?
43
+
44
+ cost = cost_text(segment_value(row, :prefill_cost_estimate))
45
+ "switch prefill #{MemoryBundle::IndexSize.count_text(tokens)} uncached prompt#{" ≈#{cost}" unless cost.empty?}"
46
+ end
47
+
48
+ private
49
+
50
+ def segment_value(row, key)
51
+ row[key] || row[key.to_s]
52
+ end
53
+
54
+ # /stats' models: block, in place of the tokens line: a row per
55
+ # segment (model, when, turns, tokens, its average speed and cost), a
56
+ # switch's prefill under the row of the model switched to, and the
57
+ # total (the tokens line's sums and breakdown).
58
+ def models_block_lines(segments, turns, tokens, token_src_label)
59
+ dated = segment_dates_differ?(segments)
60
+ cells = segments.map do |row|
61
+ speed = speed_text(segment_value(row, :avg_decode_tps), segment_value(row, :tps_source))
62
+ [model_segment_label(row), segment_span_text(row, dated: dated), turns_text(segment_value(row, :turns)),
63
+ [model_segment_tokens_text(row), speed, model_segment_cost_text(row)].reject(&:empty?).join(" ")]
64
+ end
65
+ total = ["total", "", turns_text(turns),
66
+ "#{MemoryBundle::IndexSize.count_text(tokens[:prompt_sum].to_i)} in / " \
67
+ "#{MemoryBundle::IndexSize.count_text(tokens[:completion_sum].to_i)} out " \
68
+ "(all requests, #{token_src_label}#{token_breakdown_text(tokens)})"]
69
+ widths = (0..2).map { |i| (cells + [total]).map { |cell| cell[i].length }.max }
70
+ row_text = ->(cell) { cell.each_with_index.map { |text, i| i < 3 ? text.ljust(widths[i]) : text }.join(" ") }
71
+ lines = []
72
+ segments.each_with_index do |row, i|
73
+ lines << "#{i.zero? ? "models:".ljust(18) : " " * 18}#{row_text.call(cells[i])}"
74
+ prefill = model_segment_prefill_text(row)
75
+ lines << "#{" " * 20}└ #{prefill}" unless prefill.empty?
76
+ end
77
+ lines << "#{" " * 18}#{row_text.call(total)}"
78
+ end
79
+
80
+ def turns_text(count)
81
+ "#{count.to_i} #{count.to_i == 1 ? "turn" : "turns"}"
82
+ end
83
+
84
+ # "10:02–10:41", "10:43–now" (a row with a turn running); with the
85
+ # day ("Oct 16 10:02") when the rows span more than one day.
86
+ def segment_span_text(row, dated:)
87
+ started = segment_time(segment_value(row, :started_at))
88
+ ended = segment_time(segment_value(row, :ended_at))
89
+ format_time = lambda { |time|
90
+ if time
91
+ time.strftime(dated ? "%b %-d %H:%M" : "%H:%M")
92
+ else
93
+ "?"
94
+ end
95
+ }
96
+ "#{format_time.call(started)}–#{ended ? format_time.call(ended) : "now"}"
97
+ end
98
+
99
+ def segment_dates_differ?(segments)
100
+ days = segments.flat_map { |row| [segment_value(row, :started_at), segment_value(row, :ended_at)] }
101
+ .filter_map { |stamp| segment_time(stamp)&.strftime("%F") }
102
+ days.uniq.size > 1
103
+ end
104
+
105
+ def segment_time(stamp)
106
+ stamp ? Time.iso8601(stamp.to_s).localtime : nil
107
+ rescue ArgumentError
108
+ nil
109
+ end
110
+
111
+ # The row the session's last speed came from: the newest with one.
112
+ def last_speed_label(segments)
113
+ row = segments.reverse_each.find { |segment| segment_value(segment, :last_decode_tps) }
114
+ row && model_segment_label(row)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -873,6 +873,7 @@ module Samagotchi
873
873
  end
874
874
  if command
875
875
  show_command_result(command)
876
+ refresh_status_row(clear_unknown_context: true) if command.changed.include?(:model)
876
877
  persist_recent_history(input) if command.shell
877
878
  return
878
879
  end
@@ -1400,9 +1401,10 @@ module Samagotchi
1400
1401
 
1401
1402
  # The status row under the prompt, from the Engine's state (what a
1402
1403
  # worker hands an attached TUI when it joins): drawn when it changed.
1403
- def refresh_status_row
1404
+ def refresh_status_row(clear_unknown_context: false)
1404
1405
  state = @engine.session_state_snapshot.merge(model_name: @effective_model_name)
1405
1406
  @status_row.take_state(state, default_model: @default_model_name)
1407
+ @status_row.update(context: nil) if clear_unknown_context && state.key?(:context_status) && state[:context_status].nil?
1406
1408
  refresh_children_status
1407
1409
  end
1408
1410
 
@@ -1463,13 +1465,13 @@ module Samagotchi
1463
1465
  def drain_pending_question?
1464
1466
  pq = @pending_question_event
1465
1467
  # Also check Engine's persisted pending (covers resume)
1466
- pq ||= @engine.pending_question
1468
+ pq ||= @engine.pending_question&.to_h
1467
1469
  return false unless pq
1468
1470
 
1469
1471
  # If the pending no longer matches the engine's current pending (stale
1470
1472
  # event after sync handler already answered and cleared), discard.
1471
1473
  current = @engine.pending_question
1472
- if current && (pq[:id] || pq["id"]).to_s != (current[:id] || current["id"]).to_s
1474
+ if current && (pq[:id] || pq["id"]).to_s != current.id.to_s
1473
1475
  @pending_question_event = nil
1474
1476
  return false
1475
1477
  end
@@ -38,7 +38,7 @@ module Samagotchi
38
38
  HEAD_CUT = /\[cut: \d+ of \d+ chars; read it in parts\]\z/
39
39
 
40
40
  # What a web row keeps of a tool's output, on a reload
41
- # (Web::MessageParts) and a mid-turn join (Bridge::TurnAccumulator): the
41
+ # (MessageParts) and a mid-turn join (Bridge::TurnAccumulator): the
42
42
  # row shows 300 chars and the rest on hover. A longer one keeps its head
43
43
  # and tail (preview).
44
44
  WEB_PREVIEW_CHARS = 2000
@@ -29,7 +29,7 @@ module Samagotchi
29
29
  # Runs +calls+ in order (ToolRunner#run each), yielding each run and its
30
30
  # index as it finishes. +emit+ takes the dispatch events, the runner's
31
31
  # own go to +on_stream_event+.
32
- # @return [Array<Hash>] the runs
32
+ # @return [Array<ToolRun>] the runs
33
33
  def run_batch(runner, calls, iteration:, emit:, on_stream_event:, cap:)
34
34
  emit.call({ type: :tool_dispatch_started, iteration: iteration, call_count: calls.length })
35
35
  runs = calls.each_with_index.map do |call, index|
@@ -45,7 +45,7 @@ module Samagotchi
45
45
  # The runs' activity, for the turn's tool list (none for a call whose
46
46
  # dispatcher raised).
47
47
  def activities(runs)
48
- runs.filter_map { |run| run[:activity] }
48
+ runs.filter_map(&:activity)
49
49
  end
50
50
 
51
51
  # The native loop's entry for a batch: the capped outputs joined, every image
@@ -53,14 +53,14 @@ module Samagotchi
53
53
  # each on its own tool row), and the saved-only fields one per call:
54
54
  # +ids+ (ToolIds.next_ids) as tool_ids.
55
55
  def joined(runs, ids:)
56
- images = runs.flat_map { |run| Array(run[:images]) }
57
- entry = { role: "tool_response", content: runs.map { |run| run[:capped_output] }.join(SEPARATOR) }
56
+ images = runs.flat_map { |run| Array(run.images) }
57
+ entry = { role: "tool_response", content: runs.map(&:capped_output).join(SEPARATOR) }
58
58
  unless images.empty?
59
59
  entry[:images] = images
60
- entry[:image_counts] = runs.map { |run| Array(run[:images]).size }
60
+ entry[:image_counts] = runs.map { |run| Array(run.images).size }
61
61
  end
62
- saved = { tool_params: runs.map { |run| run[:shown_params] }, tool_labels: runs.map { |run| run[:shown_label] },
63
- tool_diffs: runs.map { |run| run[:diff] }, tool_refs: runs.map { |run| run[:refs] } }
62
+ saved = { tool_params: runs.map(&:shown_params), tool_labels: runs.map(&:shown_label),
63
+ tool_diffs: runs.map(&:diff), tool_refs: runs.map(&:refs) }
64
64
  saved.each { |key, values| entry[key] = values if values.any? }
65
65
  entry[:tool_ids] = ids
66
66
  entry
@@ -70,12 +70,12 @@ module Samagotchi
70
70
  # call by +tool_call_id+, and its chi-owned id (a one-id +ids+, as
71
71
  # #joined's tool_ids).
72
72
  def single(run, tool_call_id:, ids:)
73
- entry = { role: "tool_response", content: run[:capped_output], tool_call_id: tool_call_id }
74
- entry[:images] = run[:images] if run[:images]&.any?
75
- entry[:tool_params] = run[:shown_params] if run[:shown_params]
76
- entry[:tool_labels] = run[:shown_label] if run[:shown_label]
77
- entry[:tool_diffs] = run[:diff] if run[:diff]
78
- entry[:tool_refs] = run[:refs] if run[:refs]
73
+ entry = { role: "tool_response", content: run.capped_output, tool_call_id: tool_call_id }
74
+ entry[:images] = run.images if run.images&.any?
75
+ entry[:tool_params] = run.shown_params if run.shown_params
76
+ entry[:tool_labels] = run.shown_label if run.shown_label
77
+ entry[:tool_diffs] = run.diff if run.diff
78
+ entry[:tool_refs] = run.refs if run.refs
79
79
  entry[:tool_ids] = ids
80
80
  entry
81
81
  end
@@ -7,7 +7,7 @@ require_relative "file_ref"
7
7
  module Samagotchi
8
8
  # The fields a tool row shows beyond its params, built from the call in
9
9
  # one place for every path: the live tool_call_started (ToolRunner#run)
10
- # and a reload from the saved call (Web::MessageParts#tool_part). The
10
+ # and a reload from the saved call (MessageParts#tool_part). The
11
11
  # bridge's snapshot and replay (Bridge::TurnAccumulator) copy them by
12
12
  # these lists, so a new field is added here and travels everywhere.
13
13
  #
@@ -15,6 +15,17 @@ require_relative "memory_bundle/index_sync"
15
15
  require_relative "memory_bundle/index_size"
16
16
 
17
17
  module Samagotchi
18
+ # One call's result from ToolRunner#run, read by ToolResponse (the loops'
19
+ # tool_response entries) and the loops' activity list. Fields a call didn't
20
+ # produce (images, shown_params, shown_label, refs, diff, output_file) are nil.
21
+ ToolRun = Data.define(:output, :capped_output, :truncated, :activity, :images, :shown_params, :shown_label, :refs, :diff,
22
+ :output_file) do
23
+ def initialize(output: nil, capped_output: nil, truncated: nil, activity: nil, images: nil, shown_params: nil,
24
+ shown_label: nil, refs: nil, diff: nil, output_file: nil)
25
+ super
26
+ end
27
+ end
28
+
18
29
  # The single per-call path both loops use: the tool_call_started and
19
30
  # tool_call_completed events, the guardrail gate (before_tool_call hooks
20
31
  # and their veto), dispatch through KernelLoop, the after_tool_call hook
@@ -40,7 +51,7 @@ module Samagotchi
40
51
  end
41
52
 
42
53
  # @param call_index [Integer] 1-based position of the call in its batch
43
- # @return [Hash] output:, capped_output:, truncated:, activity:,
54
+ # @return [ToolRun] output:, capped_output:, truncated:, activity:,
44
55
  # output_file: (the saved whole output of a cut execute result),
45
56
  # images: (refs) when the tool returned images the model gets to see, and
46
57
  # shown_params: the params line the live row showed, and shown_label:
@@ -135,14 +146,10 @@ module Samagotchi
135
146
  fire(:after_tool_call, { type: :after_tool_call, iteration: iteration, tool: call[:name], output: capped,
136
147
  status: status || "error", call: call.dup, elapsed_ms: elapsed_ms, result: tool_text })
137
148
 
138
- run = { output: output, capped_output: capped, truncated: truncated, activity: result[:activity] }
139
- run[:images] = images if images&.any?
140
- run[:shown_params] = params if params && plugin_tool?(call[:name])
141
- run[:shown_label] = label if label
142
- run[:refs] = refs unless refs.empty?
143
- run[:diff] = diff if diff
144
- run[:output_file] = output_file if output_file
145
- run
149
+ ToolRun.new(output: output, capped_output: capped, truncated: truncated, activity: result[:activity],
150
+ images: images&.any? ? images : nil,
151
+ shown_params: params && plugin_tool?(call[:name]) ? params : nil,
152
+ shown_label: label, refs: refs.empty? ? nil : refs, diff: diff, output_file: output_file)
146
153
  end
147
154
 
148
155
  private