samagotchi 0.80.0 → 0.81.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -1
  3. data/docs/architecture.md +9 -1
  4. data/docs/cli.md +8 -0
  5. data/docs/configuration.md +3 -0
  6. data/docs/guardrails.md +26 -3
  7. data/docs/plugins.md +8 -3
  8. data/docs/sessions.md +8 -0
  9. data/docs/testing.md +19 -0
  10. data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
  11. data/lib/samagotchi/bootstrap_command.rb +1 -1
  12. data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
  13. data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
  14. data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
  15. data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
  16. data/lib/samagotchi/bundles/system/manifest.yml +1 -1
  17. data/lib/samagotchi/engine.rb +31 -5
  18. data/lib/samagotchi/guardrails/data_readers.rb +214 -0
  19. data/lib/samagotchi/guardrails/rules.rb +4 -2
  20. data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
  21. data/lib/samagotchi/guardrails/targets.rb +14 -0
  22. data/lib/samagotchi/host_config.rb +8 -8
  23. data/lib/samagotchi/idle_client.rb +41 -11
  24. data/lib/samagotchi/idle_recap.rb +6 -1
  25. data/lib/samagotchi/idle_target.rb +12 -4
  26. data/lib/samagotchi/llm/usage.rb +9 -3
  27. data/lib/samagotchi/model_price.rb +16 -0
  28. data/lib/samagotchi/plugin/context.rb +10 -3
  29. data/lib/samagotchi/plugin/service.rb +24 -1
  30. data/lib/samagotchi/recap_setup.rb +2 -1
  31. data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
  32. data/lib/samagotchi/session_metrics.rb +46 -12
  33. data/lib/samagotchi/session_observer.rb +3 -0
  34. data/lib/samagotchi/side_usage.rb +38 -0
  35. data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
  36. data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
  37. data/lib/samagotchi/testing/host.rb +2 -2
  38. data/lib/samagotchi/testing/recorder.rb +7 -4
  39. data/lib/samagotchi/testing/sandbox.rb +5 -3
  40. data/lib/samagotchi/version.rb +1 -1
  41. data/lib/samagotchi/web/app.rb +4 -1
  42. data/lib/samagotchi/web/public/ctx.js +32 -6
  43. data/lib/samagotchi/web/public/info_bar_model.js +1 -1
  44. data/lib/samagotchi/web/public/lists_view.js +1 -1
  45. data/lib/samagotchi/web/public/stream_handlers.js +7 -0
  46. data/lib/samagotchi/web/public/timing.js +6 -1
  47. data/lib/samagotchi/web/session_summary.rb +2 -0
  48. metadata +4 -1
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../model_price"
4
+
5
+ module Samagotchi
6
+ class SessionMetrics
7
+ # The sums over a session's side requests (SideUsage: an idle recap, a
8
+ # plugin's ctx.ask_model such as /btw), kept apart from its turns'
9
+ # Totals: the snapshot's and analytics.json's +side+ block, never in
10
+ # +tokens+ or +model_segments+. They have no records: the block itself
11
+ # is saved and loaded (#to_h, .from_saved).
12
+ # +cost_sum+ is nil until a request reports a cost (a reported 0
13
+ # included), +cost_estimate_sum+ 0 until one is estimated from a price
14
+ # (ModelPrice.estimate, the turns' rule). +by_kind+ is a frozen Hash
15
+ # kind => {requests:, cost_sum:, cost_estimate_sum:} in first-seen order.
16
+ class SideTotals < Data.define(:requests, :prompt_sum, :completion_sum, :cached_sum, :cache_write_sum,
17
+ :reasoning_sum, :cost_sum, :cost_estimate_sum, :by_kind)
18
+ # The counts summed as they are (the costs and by_kind apart).
19
+ COUNTS = %i[requests prompt_sum completion_sum cached_sum cache_write_sum reasoning_sum].freeze
20
+
21
+ def self.zero
22
+ new(**COUNTS.to_h { |key| [key, 0] }, cost_sum: nil, cost_estimate_sum: 0, by_kind: {}.freeze)
23
+ end
24
+
25
+ # The block as analytics.json saved it (string keys): a non-Hash is
26
+ # zero totals, a missing or non-numeric count 0, a missing or null
27
+ # cost_sum nil.
28
+ # @param hash [Hash, nil]
29
+ # @return [SideTotals]
30
+ def self.from_saved(hash)
31
+ return zero unless hash.is_a?(Hash)
32
+
33
+ hash = hash.transform_keys(&:to_s)
34
+ kinds = hash["by_kind"].is_a?(Hash) ? hash["by_kind"] : {}
35
+ new(**COUNTS.to_h { |key| [key, count(hash[key.to_s])] }, cost_sum: cost(hash["cost_sum"]),
36
+ cost_estimate_sum: number(hash["cost_estimate_sum"]),
37
+ by_kind: kinds.each_with_object({}) do |(kind, row), rows|
38
+ row = row.is_a?(Hash) ? row.transform_keys(&:to_s) : {}
39
+ rows[kind.to_s] = { requests: count(row["requests"]), cost_sum: cost(row["cost_sum"]),
40
+ cost_estimate_sum: number(row["cost_estimate_sum"]) }.freeze
41
+ end.freeze)
42
+ end
43
+
44
+ # These totals with one side request counted in.
45
+ # @param usage [SideUsage]
46
+ # @return [SideTotals]
47
+ def add(usage)
48
+ estimate = ModelPrice.estimate(usage.price, reported_cost: usage.cost, prompt_tokens: usage.prompt_tokens,
49
+ cached_tokens: usage.cached_tokens,
50
+ cache_write_tokens: usage.cache_write_tokens,
51
+ completion_tokens: usage.completion_tokens)
52
+ one = { requests: 1, cost_sum: usage.cost&.to_f, cost_estimate_sum: estimate || 0 }
53
+ kind = usage.kind.to_s
54
+ rows = by_kind.merge(kind => sum_row(by_kind[kind], one))
55
+ with(requests: requests + 1, prompt_sum: prompt_sum + usage.prompt_tokens.to_i,
56
+ completion_sum: completion_sum + usage.completion_tokens.to_i,
57
+ cached_sum: cached_sum + usage.cached_tokens.to_i,
58
+ cache_write_sum: cache_write_sum + usage.cache_write_tokens.to_i,
59
+ reasoning_sum: reasoning_sum + usage.reasoning_tokens.to_i,
60
+ cost_sum: add_cost(cost_sum, one[:cost_sum]),
61
+ cost_estimate_sum: cost_estimate_sum + one[:cost_estimate_sum], by_kind: rows.freeze)
62
+ end
63
+
64
+ # These totals with +other+'s added (a loaded block and the requests
65
+ # counted before the load); the kinds in first-seen order, these first.
66
+ # @param other [SideTotals]
67
+ # @return [SideTotals]
68
+ def merge(other)
69
+ rows = other.by_kind.each_with_object(by_kind.dup) { |(kind, row), all| all[kind] = sum_row(all[kind], row) }
70
+ with(**COUNTS.to_h { |key| [key, public_send(key) + other.public_send(key)] },
71
+ cost_sum: add_cost(cost_sum, other.cost_sum),
72
+ cost_estimate_sum: cost_estimate_sum + other.cost_estimate_sum, by_kind: rows.freeze)
73
+ end
74
+
75
+ def empty? = requests.zero?
76
+
77
+ # The snapshot's side block (symbol keys; by_kind keyed by kind).
78
+ # @return [Hash]
79
+ def to_h
80
+ counts = COUNTS.to_h { |key| [key, public_send(key)] }
81
+ counts.merge(cost_sum: cost_sum, cost_estimate_sum: cost_estimate_sum, by_kind: by_kind.transform_values(&:dup))
82
+ end
83
+
84
+ def self.count(value) = value.is_a?(Numeric) ? value.to_i : 0
85
+
86
+ def self.number(value) = value.is_a?(Numeric) ? value : 0
87
+
88
+ def self.cost(value) = value.is_a?(Numeric) ? value : nil
89
+
90
+ private_class_method :count, :number, :cost
91
+
92
+ private
93
+
94
+ def sum_row(row, one)
95
+ return one.dup.freeze unless row
96
+
97
+ { requests: row[:requests] + one[:requests], cost_sum: add_cost(row[:cost_sum], one[:cost_sum]),
98
+ cost_estimate_sum: row[:cost_estimate_sum] + one[:cost_estimate_sum] }.freeze
99
+ end
100
+
101
+ # nil + nil stays nil (no cost reported yet); a reported one sums.
102
+ def add_cost(left, right)
103
+ return left if right.nil?
104
+ return right if left.nil?
105
+
106
+ left + right
107
+ end
108
+ end
109
+ end
110
+ end
@@ -12,6 +12,8 @@ require_relative "model_price"
12
12
  require_relative "session"
13
13
  require_relative "session_metrics/totals"
14
14
  require_relative "session_metrics/model_segment"
15
+ require_relative "session_metrics/side_totals"
16
+ require_relative "side_usage"
15
17
 
16
18
  module Samagotchi
17
19
  # SessionMetrics is a persistent, error-isolated collector of per-session
@@ -65,11 +67,14 @@ module Samagotchi
65
67
  # nil when an older file has none.
66
68
  # cost_estimate_sum: the estimated cost (hosts.<name>.models prices), 0
67
69
  # in an older file.
70
+ # side: the side requests' block (SideTotals#to_h as saved, string
71
+ # keys), nil when the session made none (or an older file).
68
72
  SavedSummary = Data.define(:ctx_pct, :prompt_sum, :completion_sum, :cached_sum, :reasoning_sum, :cost_sum,
69
- :cost_estimate_sum, :memory_index) do
70
- def initialize(memory_index: nil, cost_estimate_sum: 0, **) = super
73
+ :cost_estimate_sum, :memory_index, :side) do
74
+ def initialize(memory_index: nil, cost_estimate_sum: 0, side: nil, **) = super
71
75
 
72
- def tokens = to_h.except(:ctx_pct, :memory_index)
76
+ # The turns' token totals (the side requests' are #side, apart).
77
+ def tokens = to_h.except(:ctx_pct, :memory_index, :side)
73
78
  end
74
79
 
75
80
  # Per-turn transient state, reset on each turn_started.
@@ -199,7 +204,8 @@ module Samagotchi
199
204
  completion_sum: count.call("completion_sum"), cached_sum: count.call("cached_sum"),
200
205
  reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"),
201
206
  cost_estimate_sum: count.call("cost_estimate_sum"),
202
- memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil)
207
+ memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil,
208
+ side: data["side"].is_a?(Hash) ? data["side"] : nil)
203
209
  rescue TypeError
204
210
  nil
205
211
  end
@@ -267,6 +273,10 @@ module Samagotchi
267
273
 
268
274
  def initialize(clock: nil, wall_clock: nil)
269
275
  @mutex = Monitor.new
276
+ # Serializes #persist (snapshot and write), so two threads' saves land
277
+ # in snapshot order. Taken before @mutex, never under it: the write's
278
+ # IO doesn't hold up the events.
279
+ @persist_mutex = Mutex.new
270
280
  @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
271
281
  @wall_clock = wall_clock || -> { Time.now }
272
282
  @session_id = nil
@@ -281,6 +291,9 @@ module Samagotchi
281
291
  # and how many were loaded (a woken collector's live records start empty).
282
292
  @persisted_turns = 0
283
293
  @loaded_turns = 0
294
+ # The side requests' sums: not in reset_totals, which a load's recount
295
+ # of the turn records calls (side requests have no records).
296
+ @side = SideTotals.zero
284
297
  reset_totals
285
298
  end
286
299
 
@@ -338,6 +351,17 @@ module Samagotchi
338
351
  end
339
352
  end
340
353
 
354
+ # Count one side request (a recap, a plugin's ctx.ask_model) in the
355
+ # side block, apart from the turns' totals. Safe from any thread.
356
+ # @param usage [SideUsage]
357
+ # @return [Hash] the side block now (SideTotals#to_h)
358
+ def add_side(usage)
359
+ @mutex.synchronize do
360
+ @side = @side.add(usage)
361
+ @side.to_h
362
+ end
363
+ end
364
+
341
365
  # Event sink. Safe to call from any thread; errors are isolated by the
342
366
  # caller (SessionObserver / TerminalUI) but we still guard internally.
343
367
  # @param event [Hash]
@@ -454,6 +478,15 @@ module Samagotchi
454
478
  # turn's own end writes over it. Only the file has it.
455
479
  # @return [Boolean] true on success
456
480
  def persist(state_dir: nil, running_turn: false)
481
+ @persist_mutex.synchronize { persist_now(state_dir, running_turn) }
482
+ rescue StandardError
483
+ false
484
+ end
485
+
486
+ private
487
+
488
+ # #persist's work, under the persist mutex.
489
+ def persist_now(state_dir, running_turn)
457
490
  sid, data, turn_count = @mutex.synchronize do
458
491
  data = build_snapshot(:all).merge(active_turn: nil, active_tools: [])
459
492
  if running_turn && @turn
@@ -471,12 +504,8 @@ module Samagotchi
471
504
  AtomicFile.write(AnalyticsFile.path(dir), JSON.pretty_generate(data) + "\n")
472
505
  @mutex.synchronize { @persisted_turns = [@persisted_turns, turn_count].max }
473
506
  true
474
- rescue StandardError
475
- false
476
507
  end
477
508
 
478
- private
479
-
480
509
  # Caller holds the mutex. +records+: :recent (the live snapshot) or :all
481
510
  # (analytics.json).
482
511
  def build_snapshot(records_mode)
@@ -502,6 +531,9 @@ module Samagotchi
502
531
  # The calls the model made under an alias's name (bash run as
503
532
  # execute), by its own spelling; only when there are any.
504
533
  **(@tool_tallies[:aliased].empty? ? {} : { tool_calls_aliased: @tool_tallies[:aliased].dup }),
534
+ # The side requests (recap, ctx.ask_model), apart from +tokens+;
535
+ # only when there were any.
536
+ **(@side.empty? ? {} : { side: @side.to_h }),
505
537
  tool_errors: @tool_tallies[:errors],
506
538
  iterations_total: totals.iterations,
507
539
  gen_latency_ms: totals.gen_ms.round,
@@ -791,10 +823,10 @@ module Samagotchi
791
823
  turn.cost_sum = turn.cost_sum.to_f + turn.gen_cost if turn.gen_cost
792
824
  # A reported cost wins; a reported 0 with a price is taken as no report
793
825
  # (a gateway that doesn't bill per call); the 0 stays in cost.
794
- if turn.gen_price && (turn.gen_cost.nil? || turn.gen_cost.zero?)
795
- turn.cost_estimate_sum += turn.gen_price.cost(prompt_tokens: turn.gen_prompt_max, cached_tokens: turn.gen_cached_max,
796
- cache_write_tokens: turn.gen_cache_write_max, completion_tokens: completion)
797
- end
826
+ estimate = ModelPrice.estimate(turn.gen_price, reported_cost: turn.gen_cost, prompt_tokens: turn.gen_prompt_max,
827
+ cached_tokens: turn.gen_cached_max,
828
+ cache_write_tokens: turn.gen_cache_write_max, completion_tokens: completion)
829
+ turn.cost_estimate_sum += estimate if estimate
798
830
  turn.last_prefill_tps = turn.gen_prefill_tps.round(1) if turn.gen_prefill_tps
799
831
  speed = generation_speed(turn, completion)
800
832
  return unless speed
@@ -1098,6 +1130,8 @@ module Samagotchi
1098
1130
  end
1099
1131
  # The indexes the prompt last held, until this process builds its own.
1100
1132
  @memory_index ||= symbolized(prior["memory_index"]) if prior["memory_index"].is_a?(Hash)
1133
+ # The saved side block, with any counted before the load after it.
1134
+ @side = SideTotals.from_saved(prior["side"]).merge(@side)
1101
1135
  # The window last seen, until this process's first generation reports.
1102
1136
  window = prior["context"].is_a?(Hash) ? prior["context"] : {}
1103
1137
  return unless window["window_tokens"] && @context_window_tokens.nil?
@@ -22,6 +22,9 @@ module Samagotchi
22
22
  # * recap_ready — an idle session-recap finished generating
23
23
  # (`recap:` prose, `generation:` id). Purely additive: it never mutates
24
24
  # session.messages; each UI renders it (or not) however it likes.
25
+ # * side_usage — a side request (the recap, a plugin's ctx.ask_model) was
26
+ # counted (Engine#note_side_usage): `kind:` and `side:`, the session's
27
+ # whole side block (SessionMetrics#add_side), in fold order.
25
28
  class SessionObserver
26
29
  # Handle returned by #subscribe. Holds the observer reference and carries
27
30
  # #unsubscribe so a caller can deregister without keeping the registry.
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # One side request's usage (an idle recap, a plugin's ctx.ask_model such
5
+ # as /btw): what IdleClient reports to its on_usage callback and
6
+ # SessionMetrics#add_side folds into the session's side line, apart from
7
+ # its turns' totals.
8
+ # @!attribute kind [String] "recap", or the asking bundle's name ("btw")
9
+ # @!attribute model [String] the target's model, for the log
10
+ # @!attribute prompt_tokens [Integer] 0 when not reported (and the rest)
11
+ # @!attribute completion_tokens [Integer]
12
+ # @!attribute cached_tokens [Integer]
13
+ # @!attribute cache_write_tokens [Integer]
14
+ # @!attribute reasoning_tokens [Integer]
15
+ # @!attribute cost [Float, nil] the provider's reported cost (OpenRouter's
16
+ # usage.cost), nil when not reported
17
+ # @!attribute price [ModelPrice, nil] the target host's price for the
18
+ # model (IdleTarget#price), what the cost is estimated from
19
+ SideUsage = Data.define(:kind, :model, :prompt_tokens, :completion_tokens, :cached_tokens, :cache_write_tokens,
20
+ :reasoning_tokens, :cost, :price) do
21
+ def initialize(kind:, model: nil, prompt_tokens: 0, completion_tokens: 0, cached_tokens: 0, cache_write_tokens: 0,
22
+ reasoning_tokens: 0, cost: nil, price: nil)
23
+ super
24
+ end
25
+
26
+ # A response's usage (LLM::Usage; Usage.none's zeros when the server
27
+ # reported no counts: still one request) as a side request's.
28
+ # @param usage [LLM::Usage, nil]
29
+ # @return [SideUsage]
30
+ def self.from_usage(usage, kind:, model:, price:)
31
+ count = ->(name) { usage.respond_to?(name) ? usage.public_send(name).to_i : 0 }
32
+ new(kind: kind.to_s, model: model, prompt_tokens: count.call(:prompt_tokens),
33
+ completion_tokens: count.call(:completion_tokens), cached_tokens: count.call(:cached_tokens),
34
+ cache_write_tokens: count.call(:cache_write_tokens), reasoning_tokens: count.call(:reasoning_tokens),
35
+ cost: usage.respond_to?(:cost) ? usage.cost : nil, price: price)
36
+ end
37
+ end
38
+ end
@@ -594,6 +594,9 @@ module Samagotchi
594
594
  lines << "speed: #{speed}" if speed
595
595
  cost = stats_cost_text(tokens, price_host: snapshot[:price_host], per_model: segments.size >= 2)
596
596
  lines << "cost: #{cost}" if cost
597
+ # The side requests, never in the totals above.
598
+ side = side_requests_text(snapshot[:side])
599
+ lines << "side requests: #{side} (not in the totals above)" unless side.empty?
597
600
  lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
598
601
  lines << "cancellations: #{snapshot[:cancellations]}"
599
602
  lines << "retries: #{snapshot[:retries]}"
@@ -5,9 +5,11 @@ require_relative "../memory_bundle/index_size"
5
5
 
6
6
  module Samagotchi
7
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).
8
+ # A session's rows per model in /stats (the snapshot's model_segments),
9
+ # and its side requests' line (the snapshot's side block).
10
+ # The four public model_segment_* labels and side_requests_text are the
11
+ # words the web's ctx tooltip shares (ctx.js;
12
+ # spec/shared/labels_matrix.json model_segments, side_requests).
11
13
  # Rows come symbol- or string-keyed (the attached TUI's as JSON).
12
14
  # Included by Formatting, whose cost_text and speed_text it uses.
13
15
  module ModelSegmentsText
@@ -34,6 +36,28 @@ module Samagotchi
34
36
  .reject(&:empty?).join(" + ")
35
37
  end
36
38
 
39
+ # "3 (recap 2, btw 1) · 14.2k in / 380 out · $0.0012 + ~$0.0009": the
40
+ # side requests (the snapshot's side block: the recap, /btw and other
41
+ # ctx.ask_model calls), the kinds in first-seen order (one kind: its
42
+ # name alone), then the model rows' tokens and cost words on the
43
+ # block; "" without any (the line is hidden).
44
+ def side_requests_text(side)
45
+ return "" unless side.is_a?(Hash)
46
+
47
+ count = segment_value(side, :requests).to_i
48
+ return "" unless count.positive?
49
+
50
+ kinds = segment_value(side, :by_kind)
51
+ kinds = {} unless kinds.is_a?(Hash)
52
+ names = if kinds.size == 1
53
+ kinds.keys.first.to_s
54
+ else
55
+ kinds.map { |kind, row| "#{kind} #{(row.is_a?(Hash) ? segment_value(row, :requests) : 0).to_i}" }.join(", ")
56
+ end
57
+ head = names.empty? ? count.to_s : "#{count} (#{names})"
58
+ [head, model_segment_tokens_text(side), model_segment_cost_text(side)].reject(&:empty?).join(" · ")
59
+ end
60
+
37
61
  # "switch prefill 3.0k uncached prompt ≈$0.0060": what the switch to the
38
62
  # row's model prefilled (its cost an estimate from the configured
39
63
  # price, left out without one); "" for none.
@@ -31,9 +31,9 @@ module Samagotchi
31
31
  },
32
32
  cancelled: -> { recorder[:cancelled] },
33
33
  card: ->(**card) { recorder.card(**card) },
34
- ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil|
34
+ ask_model: lambda { |request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil|
35
35
  recorder.ask_model(request, timeout: timeout, max_tokens: max_tokens, cancel_controller: cancel_controller,
36
- model: model)
36
+ model: model, source: source)
37
37
  },
38
38
  model_name: -> { recorder[:model_name] },
39
39
  model_key: -> { recorder[:model_key] },
@@ -16,8 +16,11 @@ module Samagotchi
16
16
  Question = Data.define(:question, :options, :header, :allow_freeform, :hook)
17
17
  # An ask_model request: the request Context built (SideQuestion), its
18
18
  # limits, cancel controller and the model: it named (nil: the session's;
19
- # the kit doesn't check it against any hosts).
20
- ModelRequest = Data.define(:request, :timeout, :max_tokens, :cancel, :model)
19
+ # the kit doesn't check it against any hosts), and +source+, the asking
20
+ # bundle's name (what chi counts its side request under).
21
+ ModelRequest = Data.define(:request, :timeout, :max_tokens, :cancel, :model, :source) do
22
+ def initialize(source: nil, **) = super
23
+ end
21
24
 
22
25
  # What a bundle did at chi's edges, for a test to read: the Engine side
23
26
  # of its Context (Testing::Host) and the hook runtime (Testing::Runtime)
@@ -104,9 +107,9 @@ module Samagotchi
104
107
  # The next queued model answer (#model_answers): a String, an
105
108
  # exception to raise, or a callable given the ModelRequest. None left:
106
109
  # Plugin::ModelError, as an unreachable model.
107
- def ask_model(request, timeout:, max_tokens:, cancel_controller:, model: nil)
110
+ def ask_model(request, timeout:, max_tokens:, cancel_controller:, model: nil, source: nil)
108
111
  entry = ModelRequest.new(request: request, timeout: timeout, max_tokens: max_tokens, cancel: cancel_controller,
109
- model: model)
112
+ model: model, source: source)
110
113
  record(@model_requests, entry)
111
114
  answer = @mutex.synchronize { @model_answers.empty? ? :none : @model_answers.shift }
112
115
  raise Plugin::ModelError, "no model answer queued (bundle.model_answers << \"text\")" if answer == :none
@@ -9,8 +9,8 @@ require_relative "fake_executable"
9
9
 
10
10
  module Samagotchi
11
11
  module Testing
12
- # The environment a bundle test runs in: temp config and state homes
13
- # (config.yml, memories, ctx.data_dir, the debug log), HOME and the peer
12
+ # The environment a bundle test runs in: temp config, state, data and cache
13
+ # homes (config.yml, memories, ctx.data_dir, the debug log), HOME and the peer
14
14
  # CLIs' folders, git without the user's config, no Keychain, none of
15
15
  # the user's SAMAGOTCHI_* settings, a bin/ first on PATH for fake
16
16
  # commands. ENV is put back on #leave.
@@ -59,12 +59,14 @@ module Samagotchi
59
59
  @saved = ENV.to_h
60
60
  @outer = Sandbox.current
61
61
  ENV.each_key.select { |key| key.start_with?("SAMAGOTCHI_") && !KEPT.include?(key) }.each { |key| ENV.delete(key) }
62
- %w[config state home work bin].each { |dir| FileUtils.mkdir_p(File.join(@root, dir)) }
62
+ %w[config state data cache home work bin].each { |dir| FileUtils.mkdir_p(File.join(@root, dir)) }
63
63
  File.write(File.join(@root, "gitconfig"), "")
64
64
  ENV.update(
65
65
  "HOME" => File.join(@root, "home"),
66
66
  "XDG_CONFIG_HOME" => File.join(@root, "config"),
67
67
  "XDG_STATE_HOME" => File.join(@root, "state"),
68
+ "XDG_DATA_HOME" => File.join(@root, "data"),
69
+ "XDG_CACHE_HOME" => File.join(@root, "cache"),
68
70
  "CLAUDE_CONFIG_DIR" => File.join(@root, "home", ".claude"),
69
71
  "CODEX_HOME" => File.join(@root, "home", ".codex"),
70
72
  "GIT_CONFIG_GLOBAL" => File.join(@root, "gitconfig"),
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.80.0"
4
+ VERSION = "0.81.0"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -1798,7 +1798,10 @@ module Samagotchi
1798
1798
  # model_segments; none with one model, or from an older file).
1799
1799
  model_segments: Array(source["model_segments"]),
1800
1800
  # The memory indexes the session's prompt holds (the ctx tooltip).
1801
- memory_index: source["memory_index"]
1801
+ memory_index: source["memory_index"],
1802
+ # The side requests (recap, /btw, ctx.ask_model), apart from the
1803
+ # tokens: the ctx tooltip's own line (nil without any).
1804
+ side: source["side"]
1802
1805
  }
1803
1806
  end
1804
1807
 
@@ -142,14 +142,19 @@ export function ctxBarText(pct, windowSource = null) {
142
142
  // bar's; a /model whose new window didn't resolve, no request since:
143
143
  // Engine#announce_switched_context! sent usage null) adds "until the next
144
144
  // request", which asks for it again.
145
- export function ctxBarTip(tokens, memoryIndex, window, segments = null) {
145
+ // +side+ (the timing's side block) adds the side requests' line, in the
146
+ // "No request in this session yet" answer too (a /btw before any turn).
147
+ export function ctxBarTip(tokens, memoryIndex, window, segments = null, side = null) {
146
148
  if (num(window?.pct) === null) {
147
- if (!num(tokens?.prompt_sum) && !num(tokens?.completion_sum)) return "No request in this session yet";
149
+ if (!num(tokens?.prompt_sum) && !num(tokens?.completion_sum)) {
150
+ const sideText = sideRequestsText(side);
151
+ return `No request in this session yet${sideText ? `\nside requests: ${sideText}` : ""}`;
152
+ }
148
153
  const why = window?.afterSwitch ? "Context window unknown until the next request" : "Context window unknown";
149
- return tokensTipText(tokens, why, memoryIndex, null, segments);
154
+ return tokensTipText(tokens, why, memoryIndex, null, segments, side);
150
155
  }
151
156
 
152
- return tokensTipText(tokens, undefined, memoryIndex, window, segments);
157
+ return tokensTipText(tokens, undefined, memoryIndex, window, segments, side);
153
158
  }
154
159
 
155
160
  // The ctx tooltip's window line ({tokens:, source:, pct:, budget:} the
@@ -198,6 +203,24 @@ export function modelSegmentCostText(row) {
198
203
  return [costText(row?.cost_sum), costText(row?.cost_estimate_sum, { estimate: true })].filter(Boolean).join(" + ");
199
204
  }
200
205
 
206
+ // "3 (recap 2, btw 1) · 14.2k in / 380 out · $0.0012 + ~$0.0009": the
207
+ // session's side requests (the timing's side block: the idle recap, /btw and
208
+ // other ctx.ask_model calls, apart from its turns' totals), the kinds in
209
+ // first-seen order (one kind: its name alone), then the model rows' tokens
210
+ // and cost words on the block; "" without any (the line is hidden). As
211
+ // Formatting#side_requests_text (spec/shared/labels_matrix.json side_requests).
212
+ export function sideRequestsText(side) {
213
+ if (!side || typeof side !== "object") return "";
214
+ const requests = num(side.requests) || 0;
215
+ if (requests <= 0) return "";
216
+ const kinds = side.by_kind && typeof side.by_kind === "object" ? Object.entries(side.by_kind) : [];
217
+ const names = kinds.length === 1
218
+ ? kinds[0][0]
219
+ : kinds.map(([kind, row]) => `${kind} ${Math.round(num(row?.requests) || 0)}`).join(", ");
220
+ const head = names ? `${Math.round(requests)} (${names})` : String(Math.round(requests));
221
+ return [head, modelSegmentTokensText(side), modelSegmentCostText(side)].filter(Boolean).join(" · ");
222
+ }
223
+
201
224
  // "switch prefill 3.0k uncached prompt ≈$0.0060": what the switch to the
202
225
  // row's model prefilled (its cost an estimate from the configured price,
203
226
  // left out without one); "" for none.
@@ -246,14 +269,16 @@ function modelSegmentLines(segments) {
246
269
  // totals. A delegated session keeps its own metrics, so these are this
247
270
  // session's only.
248
271
  export function tokensTipText(tokens, title = "Context used after the last turn", memoryIndex = null, window = null,
249
- segments = null) {
272
+ segments = null, side = null) {
250
273
  const memory = memoryIndexText(memoryIndex);
251
274
  const memoryLine = memory ? `\nmemory index: ${memory}` : "";
275
+ const sideText = sideRequestsText(side);
276
+ const sideLine = sideText ? `side requests: ${sideText}` : "";
252
277
  const windowText = ctxWindowText(window);
253
278
  if (windowText) title = `${title}\n${windowText}`;
254
279
  const prompt = num(tokens?.prompt_sum) || 0;
255
280
  const completion = num(tokens?.completion_sum) || 0;
256
- if (!prompt && !completion) return `${title}${memoryLine}`;
281
+ if (!prompt && !completion) return `${title}${sideLine ? `\n${sideLine}` : ""}${memoryLine}`;
257
282
  const cached = num(tokens.cached_sum) || 0;
258
283
  const reasoning = num(tokens.reasoning_sum) || 0;
259
284
  const parts = [`in ${count(prompt)}`];
@@ -262,6 +287,7 @@ export function tokensTipText(tokens, title = "Context used after the last turn"
262
287
  const lines = [title, ...modelSegmentLines(segments), `tokens: ${parts.join(" · ")}`];
263
288
  const cost = costLine(tokens);
264
289
  if (cost) lines.push(cost);
290
+ if (sideLine) lines.push(sideLine);
265
291
  lines.push("this session only, all requests");
266
292
  return `${lines.join("\n")}${memoryLine}`;
267
293
  }
@@ -68,7 +68,7 @@ export function infoBarModel({ id, state, sessions, chain, cardTitle, badge, res
68
68
  const ctxTip = ctxBarTip(tokens, state.timing.memoryIndex,
69
69
  { tokens: state.ctxWindow, source: state.ctxWindowSource, pct: state.ctxPct,
70
70
  budget: state.llmContext?.budget_tokens, afterSwitch: state.ctxUnknownAfterSwitch },
71
- state.timing.modelSegments);
71
+ state.timing.modelSegments, state.timing.side);
72
72
  const speedTextNow = lastSpeedText(tokens);
73
73
  const avgSpeed = speedText(tokens?.avg_decode_tps, tokens?.tps_source);
74
74
  const sessionDuration = sessionDurationNow(state.timing, running);
@@ -145,7 +145,7 @@ export function cardTemplate(s, { deletable = false, archivable = false, selecta
145
145
  const folderT = folder ? html`<span class="folder" title=${s.working_directory}>${folder}</span>` : nothing;
146
146
  const archived = s.archived ? html`<span class="archived-badge" title="Archived: hidden from the lists, kept for good">archived</span>` : nothing;
147
147
  const ctx = cardCtxText(s.ctx_pct);
148
- const ctxT = ctx ? html`<span class="ctx" title=${ctxBarTip(s.tokens, s.memory_index, { pct: s.ctx_pct })}>${ctx}</span>` : nothing;
148
+ const ctxT = ctx ? html`<span class="ctx" title=${ctxBarTip(s.tokens, s.memory_index, { pct: s.ctx_pct }, null, s.side)}>${ctx}</span>` : nothing;
149
149
  const waiting = waitingBadge(s);
150
150
  const waitingT = waiting ? html`<span class=${`attn attn-${waiting.kind}`} title=${waiting.title}>${waiting.text}</span>` : nothing;
151
151
  // A turn a hook stopped wears its own badge, unless the session waits on
@@ -339,6 +339,13 @@ export function createStreamHandlers(deps) {
339
339
  question_cancelled: (data) => {
340
340
  questionCards.resolveQuestion(data.id, { cancelled: true, reason: data.reason });
341
341
  },
342
+ // A side request (the recap, /btw, a plugin's ctx.ask_model) counted:
343
+ // the server's whole side block, for the ctx tooltip's line at once.
344
+ side_usage: (data) => {
345
+ if (!data?.side || typeof data.side !== "object") return;
346
+ state().timing.side = data.side;
347
+ updateInfoBar();
348
+ },
342
349
  // One collected just as a turn started describes the chat before it.
343
350
  recap_ready: (data) => {
344
351
  // After the turn it covers, never above it.
@@ -85,6 +85,10 @@ export function normalizeTiming(data = {}) {
85
85
  // (SessionMetrics' model_segments; [] with one model), for the ctx
86
86
  // tooltip.
87
87
  modelSegments: Array.isArray(data.model_segments) ? data.model_segments.filter(validRecord) : [],
88
+ // The session's side requests (SessionMetrics' side block: recap,
89
+ // /btw, ctx.ask_model, apart from tokens), for the ctx tooltip; null
90
+ // without any.
91
+ side: validRecord(data.side) ? data.side : null,
88
92
  };
89
93
  }
90
94
 
@@ -121,11 +125,12 @@ export function mergeTiming(timing, data = {}) {
121
125
  });
122
126
  const tokens = fresh.tokens || timing?.tokens || null;
123
127
  const memoryIndex = fresh.memoryIndex || timing?.memoryIndex || null;
128
+ const side = fresh.side || timing?.side || null;
124
129
  // A reply that has the rows is the answer, an empty list too (one model
125
130
  // now): a stale list never survives it; one without them (an older
126
131
  // server) keeps the page's.
127
132
  const modelSegments = data && "model_segments" in data ? fresh.modelSegments : (timing?.modelSegments || []);
128
- return { timing: { ...fresh, turnRecords, toolRecords, tokens, memoryIndex, modelSegments },
133
+ return { timing: { ...fresh, turnRecords, toolRecords, tokens, memoryIndex, modelSegments, side },
129
134
  short: turnRecords.length < count };
130
135
  }
131
136
 
@@ -77,6 +77,8 @@ module Samagotchi
77
77
  tokens: saved&.tokens,
78
78
  # The memory indexes its prompt held (the card's ctx tooltip).
79
79
  memory_index: saved&.memory_index,
80
+ # Its side requests, apart from the tokens (the tooltip's own line).
81
+ side: saved&.side,
80
82
  # Hidden from the strip and the list unless "include archived".
81
83
  archived: ArchiveStore.archived?(session_dir),
82
84
  project_root: session.project_root(cache: root_cache),
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: samagotchi
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.80.0
4
+ version: 0.81.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dmitry Dedov
@@ -292,6 +292,7 @@ files:
292
292
  - lib/samagotchi/guardrails/approvals.rb
293
293
  - lib/samagotchi/guardrails/child_boundary.rb
294
294
  - lib/samagotchi/guardrails/context.rb
295
+ - lib/samagotchi/guardrails/data_readers.rb
295
296
  - lib/samagotchi/guardrails/gate.rb
296
297
  - lib/samagotchi/guardrails/load_failures.rb
297
298
  - lib/samagotchi/guardrails/model_size.rb
@@ -469,6 +470,7 @@ files:
469
470
  - lib/samagotchi/session_manager.rb
470
471
  - lib/samagotchi/session_metrics.rb
471
472
  - lib/samagotchi/session_metrics/model_segment.rb
473
+ - lib/samagotchi/session_metrics/side_totals.rb
472
474
  - lib/samagotchi/session_metrics/totals.rb
473
475
  - lib/samagotchi/session_model_settings.rb
474
476
  - lib/samagotchi/session_observer.rb
@@ -477,6 +479,7 @@ files:
477
479
  - lib/samagotchi/session_setup.rb
478
480
  - lib/samagotchi/sessions_command.rb
479
481
  - lib/samagotchi/setting_explained.rb
482
+ - lib/samagotchi/side_usage.rb
480
483
  - lib/samagotchi/steer.rb
481
484
  - lib/samagotchi/steer_cut.rb
482
485
  - lib/samagotchi/steer_cut_after.rb