samagotchi 0.75.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -1
  3. data/docs/architecture.md +11 -4
  4. data/docs/cli.md +13 -1
  5. data/docs/configuration.md +4 -3
  6. data/docs/guardrails.md +9 -0
  7. data/docs/internals/anthropic.md +3 -1
  8. data/docs/internals/bundles.md +20 -7
  9. data/docs/internals/context-telemetry.md +22 -0
  10. data/docs/internals/engine.md +15 -4
  11. data/docs/internals/prompt-caching.md +2 -1
  12. data/docs/memory.md +34 -0
  13. data/docs/plugins.md +43 -1
  14. data/docs/releasing.md +6 -2
  15. data/docs/sessions.md +3 -1
  16. data/lib/samagotchi/answer_command.rb +5 -5
  17. data/lib/samagotchi/bridge.rb +3 -3
  18. data/lib/samagotchi/bundle_command.rb +52 -39
  19. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
  21. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  22. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  23. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +12 -10
  24. data/lib/samagotchi/bundles/source-links/manifest.yml +2 -2
  25. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  26. data/lib/samagotchi/child_reports.rb +1 -1
  27. data/lib/samagotchi/desktop/macos.rb +1 -2
  28. data/lib/samagotchi/effective_model.rb +5 -0
  29. data/lib/samagotchi/engine.rb +105 -16
  30. data/lib/samagotchi/guardrails/parent_approvals.rb +4 -4
  31. data/lib/samagotchi/guardrails/parent_continue.rb +2 -2
  32. data/lib/samagotchi/kernel_loop.rb +13 -10
  33. data/lib/samagotchi/llm_context_override.rb +8 -6
  34. data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
  35. data/lib/samagotchi/memory_bundle/builder.rb +13 -14
  36. data/lib/samagotchi/memory_bundle/installer.rb +122 -5
  37. data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
  38. data/lib/samagotchi/memory_bundle/memory_files.rb +24 -6
  39. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  40. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  41. data/lib/samagotchi/memory_bundle/source.rb +66 -3
  42. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
  43. data/lib/samagotchi/message_parts.rb +272 -0
  44. data/lib/samagotchi/model_price.rb +4 -3
  45. data/lib/samagotchi/parent_report.rb +30 -22
  46. data/lib/samagotchi/paths.rb +15 -1
  47. data/lib/samagotchi/pending_question.rb +49 -0
  48. data/lib/samagotchi/question_desk.rb +39 -32
  49. data/lib/samagotchi/relay.rb +8 -9
  50. data/lib/samagotchi/relay_verifier.rb +2 -2
  51. data/lib/samagotchi/relay_watcher.rb +2 -2
  52. data/lib/samagotchi/reply_wait.rb +4 -4
  53. data/lib/samagotchi/row_refs.rb +1 -1
  54. data/lib/samagotchi/session.rb +22 -14
  55. data/lib/samagotchi/session_commands.rb +5 -0
  56. data/lib/samagotchi/session_manager.rb +1 -2
  57. data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
  58. data/lib/samagotchi/session_metrics/totals.rb +127 -0
  59. data/lib/samagotchi/session_metrics.rb +118 -83
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +3 -2
  61. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -2
  62. data/lib/samagotchi/terminal_ui/formatting.rb +24 -10
  63. data/lib/samagotchi/terminal_ui/model_segments_text.rb +118 -0
  64. data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
  65. data/lib/samagotchi/terminal_ui.rb +5 -3
  66. data/lib/samagotchi/tool_output_cut.rb +1 -1
  67. data/lib/samagotchi/tool_response.rb +13 -13
  68. data/lib/samagotchi/tool_row_fields.rb +1 -1
  69. data/lib/samagotchi/tool_runner.rb +16 -9
  70. data/lib/samagotchi/toolkit.rb +9 -8
  71. data/lib/samagotchi/toolkit_command.rb +2 -2
  72. data/lib/samagotchi/tools/builtins.rb +1 -5
  73. data/lib/samagotchi/tools/delegate_relay.rb +7 -7
  74. data/lib/samagotchi/tools/delegate_wait.rb +1 -1
  75. data/lib/samagotchi/tools/web_fetch.rb +3 -1
  76. data/lib/samagotchi/version.rb +1 -1
  77. data/lib/samagotchi/web/app.rb +5 -2
  78. data/lib/samagotchi/web/public/app.js +21 -58
  79. data/lib/samagotchi/web/public/chains.js +1 -1
  80. data/lib/samagotchi/web/public/composer.js +9 -5
  81. data/lib/samagotchi/web/public/ctx.js +84 -11
  82. data/lib/samagotchi/web/public/index.html +32 -36
  83. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  84. data/lib/samagotchi/web/public/markdown_place.js +53 -0
  85. data/lib/samagotchi/web/public/question_cards.js +1 -1
  86. data/lib/samagotchi/web/public/session_state.js +3 -0
  87. data/lib/samagotchi/web/public/stage_slots.js +3 -3
  88. data/lib/samagotchi/web/public/stream_handlers.js +38 -3
  89. data/lib/samagotchi/web/public/timing.js +11 -2
  90. data/lib/samagotchi/web/public/toasts.js +69 -0
  91. metadata +8 -2
  92. data/lib/samagotchi/web/message_parts.rb +0 -274
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ class SessionMetrics
5
+ # The sums over a session's finished turn records, kept as running
6
+ # counters (#add folds one record in) and read with the running turn's
7
+ # finished generations folded in (#with_running), so /stats agrees
8
+ # mid-turn. The tool tallies (by tool, aliased, errors) are counted per
9
+ # tool record, not per turn: SessionMetrics keeps those apart.
10
+ class Totals < Data.define(
11
+ :cancellations, :iterations, :gen_ms, :retries, :cuts, :capped,
12
+ :prompt_sum, :completion_sum, :cached_sum, :cache_write_sum, :reprefill_sum, :reasoning_sum,
13
+ :cost_sum, :cost_estimate_sum, :decode_ms_sum, :decode_tokens_sum,
14
+ # The newest speeds a record has, and where the decode one came from.
15
+ :last_decode_tps, :last_prefill_tps, :tps_source,
16
+ # The kinds of counts (server / estimate / mixed) the records came from.
17
+ :token_sources
18
+ )
19
+ # The record keys each sum adds up (a turn record's own names).
20
+ RECORD_SUMS = {
21
+ prompt_sum: :prompt_tokens_sum, completion_sum: :completion_tokens, iterations: :iterations,
22
+ gen_ms: :gen_ms, retries: :retries, cuts: :cuts, capped: :capped, cached_sum: :cached_tokens_sum,
23
+ cache_write_sum: :cache_write_tokens_sum, reprefill_sum: :reprefill_tokens_sum,
24
+ reasoning_sum: :reasoning_tokens, cost_sum: :cost, cost_estimate_sum: :cost_estimate,
25
+ decode_ms_sum: :decode_ms, decode_tokens_sum: :decode_tokens
26
+ }.freeze
27
+
28
+ # The running turn's (TurnState) counters each sum adds while it runs.
29
+ TURN_SUMS = {
30
+ iterations: :iteration_count, gen_ms: :gen_latency_accum, retries: :retries, cuts: :cuts,
31
+ capped: :capped, prompt_sum: :prompt_sum, completion_sum: :completion_sum, cached_sum: :cached_sum,
32
+ cache_write_sum: :cache_write_sum, reprefill_sum: :reprefill_sum, reasoning_sum: :reasoning_sum,
33
+ cost_sum: :cost_sum, cost_estimate_sum: :cost_estimate_sum, decode_ms_sum: :decode_ms_sum,
34
+ decode_tokens_sum: :decode_tokens_sum
35
+ }.freeze
36
+
37
+ def self.zero
38
+ new(cancellations: 0, **RECORD_SUMS.keys.to_h { |key| [key, 0] }, last_decode_tps: nil,
39
+ last_prefill_tps: nil, tps_source: nil, token_sources: [].freeze)
40
+ end
41
+
42
+ # These totals with one finished turn record (symbol keys, as saved;
43
+ # an older record lacks some counts) counted in.
44
+ # @param record [Hash]
45
+ # @return [Totals]
46
+ def add(record)
47
+ sums = RECORD_SUMS.to_h { |sum, key| [sum, public_send(sum) + number(record[key])] }
48
+ sums[:cancellations] = cancellations + 1 if record[:status] == "canceled"
49
+ # The newest speeds a record has (an older record has none).
50
+ if record[:decode_tps].is_a?(Numeric)
51
+ sums[:last_decode_tps] = record[:decode_tps]
52
+ sums[:tps_source] = record[:tps_source]
53
+ end
54
+ sums[:last_prefill_tps] = record[:prefill_tps] if record[:prefill_tps].is_a?(Numeric)
55
+ source = record[:token_source]
56
+ sums[:token_sources] = (token_sources | [source.to_s]).freeze unless source.nil?
57
+ with(**sums)
58
+ end
59
+
60
+ # These totals with a later segment's counts and newest speeds.
61
+ # @param later [Totals]
62
+ # @return [Totals]
63
+ def merge(later)
64
+ sums = RECORD_SUMS.keys.to_h { |key| [key, public_send(key) + later.public_send(key)] }
65
+ with(**sums, cancellations: cancellations + later.cancellations,
66
+ token_sources: (token_sources | later.token_sources).freeze,
67
+ last_decode_tps: later.last_decode_tps || last_decode_tps,
68
+ tps_source: later.last_decode_tps ? later.tps_source : tps_source,
69
+ last_prefill_tps: later.last_prefill_tps || last_prefill_tps)
70
+ end
71
+
72
+ # These totals with the running turn's counters so far (its finished
73
+ # generations, calls and iterations) added; the last speeds are the
74
+ # newest a generation reported. nil: these totals.
75
+ # @param turn [TurnState, nil]
76
+ # @return [Totals]
77
+ def with_running(turn)
78
+ return self unless turn
79
+
80
+ sums = TURN_SUMS.to_h { |sum, key| [sum, public_send(sum) + (turn.public_send(key) || 0)] }
81
+ if turn.last_decode_tps
82
+ sums[:last_decode_tps] = turn.last_decode_tps
83
+ sums[:tps_source] = turn.tps_source
84
+ end
85
+ sums[:last_prefill_tps] = turn.last_prefill_tps if turn.last_prefill_tps
86
+ sums[:token_sources] = (token_sources + turn.token_sources).freeze
87
+ with(**sums)
88
+ end
89
+
90
+ # The snapshot's tokens block.
91
+ # @return [Hash]
92
+ def tokens_block
93
+ {
94
+ prompt_sum: prompt_sum,
95
+ completion_sum: completion_sum,
96
+ cached_sum: cached_sum,
97
+ cache_write_sum: cache_write_sum,
98
+ reprefill_sum: reprefill_sum,
99
+ reasoning_sum: reasoning_sum,
100
+ cost_sum: cost_sum,
101
+ cost_estimate_sum: cost_estimate_sum,
102
+ decode_ms_sum: decode_ms_sum.round,
103
+ decode_tokens_sum: decode_tokens_sum,
104
+ avg_decode_tps: decode_ms_sum.positive? ? (decode_tokens_sum * 1000.0 / decode_ms_sum).round(1) : nil,
105
+ last_decode_tps: last_decode_tps,
106
+ last_prefill_tps: last_prefill_tps,
107
+ tps_source: tps_source,
108
+ source: combined_source
109
+ }
110
+ end
111
+
112
+ private
113
+
114
+ def number(value)
115
+ value.is_a?(Numeric) ? value : 0
116
+ end
117
+
118
+ # server, estimate, or mixed when both kinds of counts (or a turn that
119
+ # was already mixed) are in; nil before any.
120
+ def combined_source
121
+ kinds = token_sources.compact.map(&:to_s)
122
+ .flat_map { |source| source == "mixed" ? %w[server estimate] : [source] }.uniq
123
+ kinds.size > 1 ? "mixed" : kinds.first
124
+ end
125
+ end
126
+ end
127
+ end
@@ -10,6 +10,8 @@ require_relative "atomic_file"
10
10
  require_relative "token_usage"
11
11
  require_relative "model_price"
12
12
  require_relative "session"
13
+ require_relative "session_metrics/totals"
14
+ require_relative "session_metrics/model_segment"
13
15
 
14
16
  module Samagotchi
15
17
  # SessionMetrics is a persistent, error-isolated collector of per-session
@@ -54,8 +56,9 @@ module Samagotchi
54
56
  GenerationSpeed = Data.define(:decode_tps, :source)
55
57
 
56
58
  # What #finish_generation reports: the generation's speed (nil when it
57
- # had none) and the session's tokens block as the snapshot has it.
58
- GenerationReport = Data.define(:speed, :tokens)
59
+ # had none), the session's tokens block and its per-model rows
60
+ # (model_segments) as the snapshot has them.
61
+ GenerationReport = Data.define(:speed, :tokens, :model_segments)
59
62
 
60
63
  # memory_index: the memory indexes the session's prompt held
61
64
  # ({ system: { tokens:, lines: }, project: … }, string keys as saved),
@@ -142,6 +145,13 @@ module Samagotchi
142
145
  :cuts,
143
146
  :capped,
144
147
  :model,
148
+ # The model the turn ran on (asked host:model, :generation_started's
149
+ # model_ref; the first generation's), and whether its open generation
150
+ # is the first after a /model switch (its prefill is recorded).
151
+ :model_ref,
152
+ :gen_switch,
153
+ :switch_prefill_tokens,
154
+ :switch_prefill_cost_estimate,
145
155
  # A turn with no prompt of its own (:turn_started's +continue+): a
146
156
  # step-limit Continue, a reminder's or a wake's turn; its record
147
157
  # carries it.
@@ -280,6 +290,22 @@ module Samagotchi
280
290
  end
281
291
  end
282
292
 
293
+ # The model ref the last turn that named one ran on (a loaded session's
294
+ # too), nil before any: Engine#session= compares it with the effective
295
+ # model's.
296
+ # @return [String, nil]
297
+ def last_model_ref = @mutex.synchronize { @last_model_ref }
298
+
299
+ # /model's new window (Engine#announce_switched_context!), until the
300
+ # new model's first generation reports one: what the snapshot's context
301
+ # (a page's ctx % on load) counts against.
302
+ def note_context_window(tokens, source)
303
+ @mutex.synchronize do
304
+ @context_window_tokens = tokens
305
+ @context_window_source = source
306
+ end
307
+ end
308
+
283
309
  # The memory indexes the session's prompt holds (SystemPrompt#memory_index:
284
310
  # { system: { tokens:, lines: }, project: … }), set by the Engine when
285
311
  # it builds the prompt it sends; the snapshot and analytics.json carry it.
@@ -344,6 +370,7 @@ module Samagotchi
344
370
  reset_generation_tokens
345
371
  @turn.gen_price = generation_price(event[:price])
346
372
  @turn.gen_open = true
373
+ note_model_ref(event[:model_ref])
347
374
  end
348
375
  end
349
376
  when :generation_completed, :generation_cancelled
@@ -401,7 +428,8 @@ module Samagotchi
401
428
  def finish_generation
402
429
  @mutex.synchronize do
403
430
  speed = close_generation if @turn&.gen_open
404
- GenerationReport.new(speed: speed, tokens: tokens_block(@totals, @turn))
431
+ GenerationReport.new(speed: speed, tokens: @totals.with_running(@turn).tokens_block,
432
+ model_segments: ModelSegment.rows(segments_with_running(@turn)))
405
433
  end
406
434
  end
407
435
 
@@ -449,14 +477,17 @@ module Samagotchi
449
477
  # Caller holds the mutex. +records+: :recent (the live snapshot) or :all
450
478
  # (analytics.json).
451
479
  def build_snapshot(records_mode)
452
- totals = @totals
453
480
  turn = @turn
481
+ totals = @totals.with_running(turn)
454
482
  in_flight = turn ? turn.tool_calls_by_id.values : []
455
483
  {
456
484
  session_id: @session_id,
457
485
  turns: @turn_records.size + (turn ? 1 : 0),
458
- cancellations: totals[:cancellations],
459
- tokens: tokens_block(totals, turn),
486
+ cancellations: totals.cancellations,
487
+ tokens: totals.tokens_block,
488
+ # The tokens, cost and speeds per model, in order (ModelSegment.rows:
489
+ # none with one model); they add up to +tokens+.
490
+ model_segments: ModelSegment.rows(segments_with_running(turn)),
460
491
  context: context_block(@turn_records),
461
492
  memory_index: @memory_index,
462
493
  profile: @profile,
@@ -464,16 +495,16 @@ module Samagotchi
464
495
  served_model: @served_model,
465
496
  served_model_for: @served_model_for,
466
497
  tool_calls_total: @tool_records.size + in_flight.size,
467
- tool_calls_by_tool: tally_into(totals[:by_tool].dup, in_flight),
498
+ tool_calls_by_tool: tally_into(@tool_tallies[:by_tool].dup, in_flight),
468
499
  # The calls the model made under an alias's name (bash run as
469
500
  # execute), by its own spelling; only when there are any.
470
- **(totals[:aliased].empty? ? {} : { tool_calls_aliased: totals[:aliased].dup }),
471
- tool_errors: totals[:tool_errors],
472
- iterations_total: totals[:iterations] + (turn&.iteration_count || 0),
473
- gen_latency_ms: (totals[:gen_ms] + (turn&.gen_latency_accum || 0)).round,
474
- retries: totals[:retries] + (turn&.retries || 0),
475
- cuts: totals[:cuts] + (turn&.cuts || 0),
476
- capped: totals[:capped] + (turn&.capped || 0),
501
+ **(@tool_tallies[:aliased].empty? ? {} : { tool_calls_aliased: @tool_tallies[:aliased].dup }),
502
+ tool_errors: @tool_tallies[:errors],
503
+ iterations_total: totals.iterations,
504
+ gen_latency_ms: totals.gen_ms.round,
505
+ retries: totals.retries,
506
+ cuts: totals.cuts,
507
+ capped: totals.capped,
477
508
  started_at: @started_at,
478
509
  last_activity_at: @last_activity_at,
479
510
  # From the session's first start (an earlier process's too), to now.
@@ -484,31 +515,19 @@ module Samagotchi
484
515
  }
485
516
  end
486
517
 
487
- # The session's token totals, with the running turn's finished
488
- # generations; the last speeds are the newest a generation reported.
489
- # Caller holds the mutex.
490
- def tokens_block(totals, turn)
491
- add = ->(key) { totals[key] + (turn&.public_send(key) || 0) }
492
- decode_ms = add.call(:decode_ms_sum)
493
- decode_tokens = add.call(:decode_tokens_sum)
494
- speed_turn = turn&.last_decode_tps ? turn : nil
495
- {
496
- prompt_sum: add.call(:prompt_sum),
497
- completion_sum: add.call(:completion_sum),
498
- cached_sum: add.call(:cached_sum),
499
- cache_write_sum: add.call(:cache_write_sum),
500
- reprefill_sum: add.call(:reprefill_sum),
501
- reasoning_sum: add.call(:reasoning_sum),
502
- cost_sum: add.call(:cost_sum),
503
- cost_estimate_sum: add.call(:cost_estimate_sum),
504
- decode_ms_sum: decode_ms.round,
505
- decode_tokens_sum: decode_tokens,
506
- avg_decode_tps: decode_ms.positive? ? (decode_tokens * 1000.0 / decode_ms).round(1) : nil,
507
- last_decode_tps: speed_turn ? speed_turn.last_decode_tps : totals[:last_decode_tps],
508
- last_prefill_tps: turn&.last_prefill_tps || totals[:last_prefill_tps],
509
- tps_source: speed_turn ? speed_turn.tps_source : totals[:tps_source],
510
- source: combined_source(totals[:token_sources] + (turn&.token_sources || []))
511
- }
518
+ # The model segments with the running turn in: the last one's when it
519
+ # runs on that model or names none yet, else a new one. Caller holds
520
+ # the mutex.
521
+ def segments_with_running(turn)
522
+ return @segments unless turn
523
+
524
+ last = @segments.last
525
+ ref = turn.model_ref
526
+ if last.nil? || (ref && last.model_ref != ref)
527
+ return @segments + [ModelSegment.empty(ref, turn.started_at).with_running(turn)]
528
+ end
529
+
530
+ @segments[0...-1] + [last.with_running(turn)]
512
531
  end
513
532
 
514
533
  # The records a snapshot carries, copied. :all is every record; :recent
@@ -571,6 +590,8 @@ module Samagotchi
571
590
  cuts: 0,
572
591
  capped: 0,
573
592
  model: nil,
593
+ model_ref: nil,
594
+ gen_switch: false,
574
595
  continue: event[:continue] ? true : false,
575
596
  resumed: event[:resumed] ? true : false,
576
597
  id: event[:turn_id] || SecureRandom.uuid,
@@ -689,6 +710,7 @@ module Samagotchi
689
710
  def close_generation
690
711
  turn = @turn
691
712
  turn.gen_open = false
713
+ note_switch_prefill(turn)
692
714
  speed = nil
693
715
  if turn.gen_had_server
694
716
  completion = turn.gen_completion_max
@@ -712,6 +734,42 @@ module Samagotchi
712
734
  speed
713
735
  end
714
736
 
737
+ # The turn's model, from its first generation that names one; that
738
+ # generation is a switch's when the last turn that named a model ran on
739
+ # another (a turn with none, stopped before it asked, never counts; nor
740
+ # does a legacy session's first named one). Caller holds the mutex.
741
+ def note_model_ref(ref)
742
+ return if @turn.model_ref || ref.to_s.empty?
743
+
744
+ @turn.model_ref = ref.to_s
745
+ @turn.gen_switch = !@last_model_ref.nil? && @last_model_ref != @turn.model_ref
746
+ @last_model_ref = @turn.model_ref
747
+ end
748
+
749
+ # The first generation after a switch prefilled the history on the new
750
+ # model: its uncached prompt (the whole prompt less the cached reads;
751
+ # llama.cpp's native tokens_evaluated is the whole prompt too), with its
752
+ # cost estimated from the configured price, never a completion's. None
753
+ # for a generation with estimated tokens only or a prefill too small to
754
+ # be one (REPREFILL_MIN_TOKENS). Caller holds the mutex.
755
+ def note_switch_prefill(turn)
756
+ return unless turn.gen_switch
757
+
758
+ turn.gen_switch = false
759
+ return unless turn.gen_had_server
760
+
761
+ tokens = [turn.gen_prompt_max - turn.gen_cached_max, 0].max
762
+ return if tokens < TokenUsage::REPREFILL_MIN_TOKENS
763
+
764
+ turn.switch_prefill_tokens = tokens
765
+ return unless turn.gen_price
766
+
767
+ turn.switch_prefill_cost_estimate = turn.gen_price.cost(prompt_tokens: turn.gen_prompt_max,
768
+ cached_tokens: turn.gen_cached_max,
769
+ cache_write_tokens: turn.gen_cache_write_max,
770
+ completion_tokens: 0)
771
+ end
772
+
715
773
  # The generation's cached/reasoning counts, cost and decode speed, into
716
774
  # the turn. The speed is the server's (llama.cpp's predicted_per_second
717
775
  # over its predicted_ms) or, without one, an estimate: the completion
@@ -781,6 +839,7 @@ module Samagotchi
781
839
  sources = turn.token_sources
782
840
  {
783
841
  model: turn.model,
842
+ model_ref: turn.model_ref,
784
843
  generations: turn.generations,
785
844
  prompt_tokens: turn.prompt_last,
786
845
  prompt_tokens_max: turn.prompt_max,
@@ -815,6 +874,8 @@ module Samagotchi
815
874
  fields[:reprefill_tokens_sum] = turn.reprefill_sum if turn.reprefill_sum.positive?
816
875
  fields.merge!(cost: turn.cost_sum, cost_source: "reported") if turn.cost_sum
817
876
  fields[:cost_estimate] = turn.cost_estimate_sum if turn.cost_estimate_sum.positive?
877
+ fields[:switch_prefill_tokens] = turn.switch_prefill_tokens if turn.switch_prefill_tokens
878
+ fields[:switch_prefill_cost_estimate] = turn.switch_prefill_cost_estimate if turn.switch_prefill_cost_estimate
818
879
  fields
819
880
  end
820
881
 
@@ -1017,6 +1078,7 @@ module Samagotchi
1017
1078
  reset_totals
1018
1079
  @turn_records.each { |record| count_turn(record) }
1019
1080
  @tool_records.each { |record| count_tool(record) }
1081
+ @last_model_ref = @turn.model_ref if @turn&.model_ref
1020
1082
  @started_at = earliest_timestamp(prior["started_at"], @started_at)
1021
1083
  @last_activity_at ||= prior["last_activity_at"]
1022
1084
  # The served model last reported, with the name asked for then (the
@@ -1057,48 +1119,32 @@ module Samagotchi
1057
1119
  end
1058
1120
 
1059
1121
  # The session totals over the finished records, as running counters
1060
- # (#snapshot adds the running turn). Caller holds the mutex.
1122
+ # (#snapshot adds the running turn), and the tool tallies over the tool
1123
+ # records. Caller holds the mutex.
1061
1124
  def reset_totals
1062
- @totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
1063
- gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, aliased: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
1064
- reprefill_sum: 0, reasoning_sum: 0,
1065
- cost_sum: 0, cost_estimate_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
1066
- last_prefill_tps: nil, tps_source: nil }
1125
+ @totals = Totals.zero
1126
+ @segments = []
1127
+ @last_model_ref = nil
1128
+ @tool_tallies = { by_tool: {}, aliased: {}, errors: 0 }
1067
1129
  end
1068
1130
 
1069
1131
  def count_turn(record)
1070
- totals = @totals
1071
- totals[:cancellations] += 1 if record[:status] == "canceled"
1072
- totals[:prompt_sum] += number(record[:prompt_tokens_sum])
1073
- totals[:completion_sum] += number(record[:completion_tokens])
1074
- totals[:iterations] += number(record[:iterations])
1075
- totals[:gen_ms] += number(record[:gen_ms])
1076
- totals[:retries] += number(record[:retries])
1077
- totals[:cuts] += number(record[:cuts])
1078
- totals[:capped] += number(record[:capped])
1079
- totals[:cached_sum] += number(record[:cached_tokens_sum])
1080
- totals[:cache_write_sum] += number(record[:cache_write_tokens_sum])
1081
- totals[:reprefill_sum] += number(record[:reprefill_tokens_sum])
1082
- totals[:reasoning_sum] += number(record[:reasoning_tokens])
1083
- totals[:cost_sum] += number(record[:cost])
1084
- totals[:cost_estimate_sum] += number(record[:cost_estimate])
1085
- totals[:decode_ms_sum] += number(record[:decode_ms])
1086
- totals[:decode_tokens_sum] += number(record[:decode_tokens])
1087
- # The newest speeds a record has (an older record has none).
1088
- if record[:decode_tps].is_a?(Numeric)
1089
- totals[:last_decode_tps] = record[:decode_tps]
1090
- totals[:tps_source] = record[:tps_source]
1132
+ @totals = @totals.add(record)
1133
+ if ModelSegment.splits?(@segments.last, record)
1134
+ @segments << ModelSegment.open(record)
1135
+ else
1136
+ @segments[-1] = @segments.last.add(record)
1091
1137
  end
1092
- totals[:last_prefill_tps] = record[:prefill_tps] if record[:prefill_tps].is_a?(Numeric)
1093
- source = record[:token_source]
1094
- totals[:token_sources] |= [source.to_s] unless source.nil?
1138
+ # The model the last turn that named one ran on: what a switch is
1139
+ # told by (#note_model_ref).
1140
+ @last_model_ref = record[:model_ref].to_s unless record[:model_ref].to_s.empty?
1095
1141
  end
1096
1142
 
1097
1143
  def count_tool(record)
1098
- @totals[:tool_errors] += 1 if record[:status] == "error"
1099
- tally_into(@totals[:by_tool], [record])
1144
+ @tool_tallies[:errors] += 1 if record[:status] == "error"
1145
+ tally_into(@tool_tallies[:by_tool], [record])
1100
1146
  called_as = record[:called_as].to_s
1101
- @totals[:aliased][called_as] = @totals[:aliased].fetch(called_as, 0) + 1 unless called_as.empty?
1147
+ @tool_tallies[:aliased][called_as] = @tool_tallies[:aliased].fetch(called_as, 0) + 1 unless called_as.empty?
1102
1148
  end
1103
1149
 
1104
1150
  def tally_into(counts, tools)
@@ -1109,17 +1155,6 @@ module Samagotchi
1109
1155
  counts
1110
1156
  end
1111
1157
 
1112
- def number(value)
1113
- value.is_a?(Numeric) ? value : 0
1114
- end
1115
-
1116
- # server, estimate, or mixed when both kinds of counts (or a turn that
1117
- # was already mixed) are in; nil before any.
1118
- def combined_source(sources)
1119
- kinds = sources.compact.map(&:to_s).flat_map { |source| source == "mixed" ? %w[server estimate] : [source] }.uniq
1120
- kinds.size > 1 ? "mixed" : kinds.first
1121
- end
1122
-
1123
1158
  def earliest_timestamp(*timestamps)
1124
1159
  timestamps.compact.min_by { |value| Time.iso8601(value.to_s) }
1125
1160
  rescue ArgumentError
@@ -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).
@@ -435,13 +438,18 @@ module Samagotchi
435
438
  # /stats' cost: the reported part and the estimated one, each when present.
436
439
  # +price_host+ names the host whose hosts.<name>.models prices the
437
440
  # estimate came from (Engine#stats_snapshot; nil from an older worker).
438
- def stats_cost_text(tokens, price_host: nil)
441
+ # +per_model+: the session ran on several models (model_segments), each
442
+ # priced from its own host's prices.
443
+ def stats_cost_text(tokens, price_host: nil, per_model: false)
439
444
  reported = cost_text(tokens[:cost_sum])
440
445
  estimated = cost_text(tokens[:cost_estimate_sum], estimate: true)
441
446
  return nil if reported.empty? && estimated.empty?
442
447
  return "#{reported} (this session only)" if estimated.empty?
443
448
 
444
- source = price_host.to_s.empty? ? "configured prices" : "hosts.#{price_host}.models prices"
449
+ source = if per_model then "each model's host prices"
450
+ elsif price_host.to_s.empty? then "configured prices"
451
+ else "hosts.#{price_host}.models prices"
452
+ end
445
453
  estimate = "#{estimated} from #{source}"
446
454
  "#{reported.empty? ? estimate : "#{reported} reported + #{estimate}"} (this session only)"
447
455
  end
@@ -574,12 +582,17 @@ module Samagotchi
574
582
  lines << " called as: #{aliased.map { |k, v| "#{k}=#{v}" }.join(", ")}"
575
583
  end
576
584
  lines << "iterations: #{snapshot[:iterations_total]}"
577
- # Summed over every request: each prompt is sent in full again.
578
- lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})" \
579
- "#{token_breakdown_text(tokens)}"
580
- speed = stats_speed_text(tokens)
585
+ segments = Array(snapshot[:model_segments])
586
+ if segments.size >= 2
587
+ lines.concat(models_block_lines(segments, snapshot[:turns], tokens, token_src_label))
588
+ else
589
+ # Summed over every request: each prompt is sent in full again.
590
+ lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})" \
591
+ "#{token_breakdown_text(tokens)}"
592
+ end
593
+ speed = stats_speed_text(tokens, last_model: segments.size >= 2 ? last_speed_label(segments) : nil)
581
594
  lines << "speed: #{speed}" if speed
582
- cost = stats_cost_text(tokens, price_host: snapshot[:price_host])
595
+ cost = stats_cost_text(tokens, price_host: snapshot[:price_host], per_model: segments.size >= 2)
583
596
  lines << "cost: #{cost}" if cost
584
597
  lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
585
598
  lines << "cancellations: #{snapshot[:cancellations]}"
@@ -682,15 +695,16 @@ module Samagotchi
682
695
 
683
696
  # "87 tok/s out, 1.9k tok/s prompt (last, server), avg 81 tok/s", or nil
684
697
  # before a generation had a speed. The prompt (prefill) speed only when
685
- # the server reported it.
686
- def stats_speed_text(tokens)
698
+ # the server reported it. +last_model+: the model the last speed came
699
+ # from, named when the session ran on several.
700
+ def stats_speed_text(tokens, last_model: nil)
687
701
  last = tokens[:last_decode_tps]
688
702
  return nil unless last
689
703
 
690
704
  source = tokens[:tps_source].to_s
691
705
  text = "#{speed_text(last, source)} out"
692
706
  text << ", #{speed_text(tokens[:last_prefill_tps], "server")} prompt" if tokens[:last_prefill_tps]
693
- text << " (last, #{source})"
707
+ text << " (last, #{source}#{", #{last_model}" if last_model})"
694
708
  text << ", avg #{speed_text(tokens[:avg_decode_tps], source)}" if tokens[:avg_decode_tps]
695
709
  text
696
710
  end