samagotchi 0.75.0 → 0.76.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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -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 +14 -3
  11. data/docs/internals/prompt-caching.md +2 -1
  12. data/docs/memory.md +34 -0
  13. data/docs/plugins.md +1 -1
  14. data/docs/releasing.md +6 -2
  15. data/docs/sessions.md +3 -1
  16. data/lib/samagotchi/bundle_command.rb +52 -39
  17. data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
  18. data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
  19. data/lib/samagotchi/bundles/core/manifest.yml +2 -2
  20. data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  22. data/lib/samagotchi/desktop/macos.rb +1 -2
  23. data/lib/samagotchi/effective_model.rb +5 -0
  24. data/lib/samagotchi/engine.rb +84 -4
  25. data/lib/samagotchi/kernel_loop.rb +13 -10
  26. data/lib/samagotchi/llm_context_override.rb +8 -6
  27. data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
  28. data/lib/samagotchi/memory_bundle/builder.rb +11 -14
  29. data/lib/samagotchi/memory_bundle/installer.rb +122 -5
  30. data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
  31. data/lib/samagotchi/memory_bundle/memory_files.rb +24 -6
  32. data/lib/samagotchi/memory_bundle/profile.rb +1 -1
  33. data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
  34. data/lib/samagotchi/memory_bundle/source.rb +66 -3
  35. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
  36. data/lib/samagotchi/model_price.rb +4 -3
  37. data/lib/samagotchi/paths.rb +15 -1
  38. data/lib/samagotchi/session_commands.rb +2 -0
  39. data/lib/samagotchi/session_manager.rb +1 -2
  40. data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
  41. data/lib/samagotchi/session_metrics/totals.rb +127 -0
  42. data/lib/samagotchi/session_metrics.rb +118 -83
  43. data/lib/samagotchi/terminal_ui/formatting.rb +127 -10
  44. data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
  45. data/lib/samagotchi/toolkit.rb +9 -8
  46. data/lib/samagotchi/toolkit_command.rb +2 -2
  47. data/lib/samagotchi/tools/builtins.rb +1 -5
  48. data/lib/samagotchi/version.rb +1 -1
  49. data/lib/samagotchi/web/app.rb +3 -0
  50. data/lib/samagotchi/web/public/app.js +4 -3
  51. data/lib/samagotchi/web/public/chains.js +1 -1
  52. data/lib/samagotchi/web/public/composer.js +9 -5
  53. data/lib/samagotchi/web/public/ctx.js +73 -11
  54. data/lib/samagotchi/web/public/index.html +30 -35
  55. data/lib/samagotchi/web/public/info_bar_model.js +2 -1
  56. data/lib/samagotchi/web/public/question_cards.js +1 -1
  57. data/lib/samagotchi/web/public/session_state.js +3 -0
  58. data/lib/samagotchi/web/public/stage_slots.js +3 -3
  59. data/lib/samagotchi/web/public/stream_handlers.js +32 -1
  60. data/lib/samagotchi/web/public/timing.js +11 -2
  61. metadata +3 -1
@@ -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
@@ -435,17 +435,61 @@ module Samagotchi
435
435
  # /stats' cost: the reported part and the estimated one, each when present.
436
436
  # +price_host+ names the host whose hosts.<name>.models prices the
437
437
  # estimate came from (Engine#stats_snapshot; nil from an older worker).
438
- def stats_cost_text(tokens, price_host: nil)
438
+ # +per_model+: the session ran on several models (model_segments), each
439
+ # priced from its own host's prices.
440
+ def stats_cost_text(tokens, price_host: nil, per_model: false)
439
441
  reported = cost_text(tokens[:cost_sum])
440
442
  estimated = cost_text(tokens[:cost_estimate_sum], estimate: true)
441
443
  return nil if reported.empty? && estimated.empty?
442
444
  return "#{reported} (this session only)" if estimated.empty?
443
445
 
444
- source = price_host.to_s.empty? ? "configured prices" : "hosts.#{price_host}.models prices"
446
+ source = if per_model then "each model's host prices"
447
+ elsif price_host.to_s.empty? then "configured prices"
448
+ else "hosts.#{price_host}.models prices"
449
+ end
445
450
  estimate = "#{estimated} from #{source}"
446
451
  "#{reported.empty? ? estimate : "#{reported} reported + #{estimate}"} (this session only)"
447
452
  end
448
453
 
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
+
449
493
  private
450
494
 
451
495
  # The recap shown on return: dim, one "recap>" block, noting how many
@@ -574,12 +618,17 @@ module Samagotchi
574
618
  lines << " called as: #{aliased.map { |k, v| "#{k}=#{v}" }.join(", ")}"
575
619
  end
576
620
  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)
621
+ segments = Array(snapshot[:model_segments])
622
+ if segments.size >= 2
623
+ lines.concat(models_block_lines(segments, snapshot[:turns], tokens, token_src_label))
624
+ else
625
+ # Summed over every request: each prompt is sent in full again.
626
+ lines << "tokens in/out: #{tokens[:prompt_sum].to_i}/#{tokens[:completion_sum].to_i} (all requests, #{token_src_label})" \
627
+ "#{token_breakdown_text(tokens)}"
628
+ end
629
+ speed = stats_speed_text(tokens, last_model: segments.size >= 2 ? last_speed_label(segments) : nil)
581
630
  lines << "speed: #{speed}" if speed
582
- cost = stats_cost_text(tokens, price_host: snapshot[:price_host])
631
+ cost = stats_cost_text(tokens, price_host: snapshot[:price_host], per_model: segments.size >= 2)
583
632
  lines << "cost: #{cost}" if cost
584
633
  lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
585
634
  lines << "cancellations: #{snapshot[:cancellations]}"
@@ -682,19 +731,87 @@ module Samagotchi
682
731
 
683
732
  # "87 tok/s out, 1.9k tok/s prompt (last, server), avg 81 tok/s", or nil
684
733
  # before a generation had a speed. The prompt (prefill) speed only when
685
- # the server reported it.
686
- def stats_speed_text(tokens)
734
+ # the server reported it. +last_model+: the model the last speed came
735
+ # from, named when the session ran on several.
736
+ def stats_speed_text(tokens, last_model: nil)
687
737
  last = tokens[:last_decode_tps]
688
738
  return nil unless last
689
739
 
690
740
  source = tokens[:tps_source].to_s
691
741
  text = "#{speed_text(last, source)} out"
692
742
  text << ", #{speed_text(tokens[:last_prefill_tps], "server")} prompt" if tokens[:last_prefill_tps]
693
- text << " (last, #{source})"
743
+ text << " (last, #{source}#{", #{last_model}" if last_model})"
694
744
  text << ", avg #{speed_text(tokens[:avg_decode_tps], source)}" if tokens[:avg_decode_tps]
695
745
  text
696
746
  end
697
747
 
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
+
698
815
  def format_elapsed_duration(duration_ms)
699
816
  duration_ms = duration_ms.to_f
700
817
  return "" if duration_ms.negative?
@@ -89,7 +89,9 @@ module Samagotchi
89
89
  # hint). Other events change nothing.
90
90
  def take_event(event)
91
91
  case event[:type]
92
- when :context_status then update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
92
+ # usage nil (a /model whose window didn't resolve) clears it.
93
+ when :context_status
94
+ update(context: event[:usage] ? { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] } : nil)
93
95
  when :used_memories_updated then update(used_memories: Array(event[:used_memory_names]))
94
96
  when :generation_completed
95
97
  update(served: [event[:served_model], event[:requested_model], event[:served_expected_by]]) if event[:served_model]