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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +30 -1
- data/docs/architecture.md +9 -1
- data/docs/cli.md +8 -0
- data/docs/configuration.md +3 -0
- data/docs/guardrails.md +26 -3
- data/docs/plugins.md +8 -3
- data/docs/sessions.md +8 -0
- data/docs/testing.md +19 -0
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -1
- data/lib/samagotchi/bootstrap_command.rb +1 -1
- data/lib/samagotchi/bundles/guardrails/guardrails/rules.yml +4 -4
- data/lib/samagotchi/bundles/guardrails/manifest.yml +1 -1
- data/lib/samagotchi/bundles/slow-commands/manifest.yml +2 -2
- data/lib/samagotchi/bundles/slow-commands/plugin.rb +2 -1
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/engine.rb +31 -5
- data/lib/samagotchi/guardrails/data_readers.rb +214 -0
- data/lib/samagotchi/guardrails/rules.rb +4 -2
- data/lib/samagotchi/guardrails/shell_lex.rb +166 -28
- data/lib/samagotchi/guardrails/targets.rb +14 -0
- data/lib/samagotchi/host_config.rb +8 -8
- data/lib/samagotchi/idle_client.rb +41 -11
- data/lib/samagotchi/idle_recap.rb +6 -1
- data/lib/samagotchi/idle_target.rb +12 -4
- data/lib/samagotchi/llm/usage.rb +9 -3
- data/lib/samagotchi/model_price.rb +16 -0
- data/lib/samagotchi/plugin/context.rb +10 -3
- data/lib/samagotchi/plugin/service.rb +24 -1
- data/lib/samagotchi/recap_setup.rb +2 -1
- data/lib/samagotchi/session_metrics/side_totals.rb +110 -0
- data/lib/samagotchi/session_metrics.rb +46 -12
- data/lib/samagotchi/session_observer.rb +3 -0
- data/lib/samagotchi/side_usage.rb +38 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +3 -0
- data/lib/samagotchi/terminal_ui/model_segments_text.rb +27 -3
- data/lib/samagotchi/testing/host.rb +2 -2
- data/lib/samagotchi/testing/recorder.rb +7 -4
- data/lib/samagotchi/testing/sandbox.rb +5 -3
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +4 -1
- data/lib/samagotchi/web/public/ctx.js +32 -6
- data/lib/samagotchi/web/public/info_bar_model.js +1 -1
- data/lib/samagotchi/web/public/lists_view.js +1 -1
- data/lib/samagotchi/web/public/stream_handlers.js +7 -0
- data/lib/samagotchi/web/public/timing.js +6 -1
- data/lib/samagotchi/web/session_summary.rb +2 -0
- 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
|
-
|
|
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
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
#
|
|
10
|
-
#
|
|
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
|
-
|
|
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
|
|
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"),
|
data/lib/samagotchi/version.rb
CHANGED
data/lib/samagotchi/web/app.rb
CHANGED
|
@@ -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
|
-
|
|
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))
|
|
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.
|
|
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
|