samagotchi 0.74.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -1
- data/docs/architecture.md +11 -4
- data/docs/cli.md +13 -1
- data/docs/configuration.md +4 -3
- data/docs/guardrails.md +9 -0
- data/docs/internals/anthropic.md +3 -1
- data/docs/internals/bundles.md +21 -7
- data/docs/internals/context-telemetry.md +22 -0
- data/docs/internals/engine.md +14 -3
- data/docs/internals/prompt-caching.md +2 -1
- data/docs/memory.md +48 -1
- data/docs/plugins.md +2 -1
- data/docs/releasing.md +6 -2
- data/docs/sessions.md +7 -5
- data/lib/samagotchi/bundle_command.rb +52 -39
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +19 -2
- data/lib/samagotchi/bundles/core/manifest.yml +2 -2
- data/lib/samagotchi/bundles/dev/manifest.yml +2 -2
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/desktop/macos.rb +1 -2
- data/lib/samagotchi/effective_model.rb +5 -0
- data/lib/samagotchi/engine.rb +84 -4
- data/lib/samagotchi/kernel_loop.rb +13 -10
- data/lib/samagotchi/llm_context_override.rb +8 -6
- data/lib/samagotchi/memory_bundle/asset_installer.rb +34 -5
- data/lib/samagotchi/memory_bundle/builder.rb +11 -14
- data/lib/samagotchi/memory_bundle/installer.rb +140 -9
- data/lib/samagotchi/memory_bundle/manifest.rb +20 -1
- data/lib/samagotchi/memory_bundle/memory_files.rb +65 -0
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/shipped_update.rb +2 -1
- data/lib/samagotchi/memory_bundle/source.rb +66 -3
- data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -2
- data/lib/samagotchi/model_price.rb +4 -3
- data/lib/samagotchi/paths.rb +15 -1
- data/lib/samagotchi/session.rb +1 -1
- data/lib/samagotchi/session_chain.rb +2 -8
- data/lib/samagotchi/session_commands.rb +2 -0
- data/lib/samagotchi/session_manager.rb +1 -2
- data/lib/samagotchi/session_metrics/model_segment.rb +109 -0
- data/lib/samagotchi/session_metrics/totals.rb +127 -0
- data/lib/samagotchi/session_metrics.rb +118 -83
- data/lib/samagotchi/terminal_ui/formatting.rb +127 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +3 -1
- data/lib/samagotchi/toolkit.rb +9 -8
- data/lib/samagotchi/toolkit_command.rb +2 -2
- data/lib/samagotchi/tools/builtins.rb +1 -5
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +3 -0
- data/lib/samagotchi/web/public/app.js +4 -3
- data/lib/samagotchi/web/public/chains.js +42 -21
- data/lib/samagotchi/web/public/composer.js +9 -5
- data/lib/samagotchi/web/public/ctx.js +73 -11
- data/lib/samagotchi/web/public/index.html +32 -35
- data/lib/samagotchi/web/public/info_bar.js +4 -4
- data/lib/samagotchi/web/public/info_bar_model.js +18 -8
- data/lib/samagotchi/web/public/lists_view.js +2 -2
- data/lib/samagotchi/web/public/question_cards.js +1 -1
- data/lib/samagotchi/web/public/session_state.js +7 -0
- data/lib/samagotchi/web/public/stage_slots.js +3 -3
- data/lib/samagotchi/web/public/stream_handlers.js +32 -1
- data/lib/samagotchi/web/public/timing.js +11 -2
- metadata +4 -1
|
@@ -6,7 +6,7 @@ require_relative "recap_store"
|
|
|
6
6
|
|
|
7
7
|
module Samagotchi
|
|
8
8
|
# Session chains: a session that continues an earlier one (Session#continues),
|
|
9
|
-
# "the next
|
|
9
|
+
# "the next run of the same routine". The chain is derived, never stored:
|
|
10
10
|
# follow +continues+ back for the earlier links, forward (the session whose
|
|
11
11
|
# +continues+ names this one) for the later ones. A link is continued at
|
|
12
12
|
# most once (SessionManager.continue_session refuses a second), so the
|
|
@@ -69,7 +69,7 @@ module Samagotchi
|
|
|
69
69
|
# @param recap [Hash, nil] RecapStore.read's
|
|
70
70
|
# @return [String]
|
|
71
71
|
def note_text(previous, recap:)
|
|
72
|
-
head = "This session continues #{previous.id[0, 8]} (#{
|
|
72
|
+
head = "This session continues #{previous.id[0, 8]} (#{clock(previous.created_at) || previous.created_at}), the previous link of its chain."
|
|
73
73
|
text = recap && recap[:text].to_s.strip
|
|
74
74
|
return "#{head} It left no recap." if text.nil? || text.empty?
|
|
75
75
|
|
|
@@ -102,12 +102,6 @@ module Samagotchi
|
|
|
102
102
|
nil
|
|
103
103
|
end
|
|
104
104
|
|
|
105
|
-
def day(iso)
|
|
106
|
-
Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d")
|
|
107
|
-
rescue ArgumentError
|
|
108
|
-
iso.to_s
|
|
109
|
-
end
|
|
110
|
-
|
|
111
105
|
def clock(iso)
|
|
112
106
|
iso && Time.iso8601(iso.to_s).localtime.strftime("%Y-%m-%d %H:%M")
|
|
113
107
|
rescue ArgumentError
|
|
@@ -592,6 +592,8 @@ module Samagotchi
|
|
|
592
592
|
@engine.switch_model!(name, persist_default: persist_default)
|
|
593
593
|
@default_model = @engine.default_model_name if persist_default
|
|
594
594
|
persist_session_model
|
|
595
|
+
# The ctx % on the new model's window, before its first turn.
|
|
596
|
+
@engine.announce_switched_context!
|
|
595
597
|
end
|
|
596
598
|
|
|
597
599
|
def persist_session_model
|
|
@@ -244,8 +244,7 @@ module Samagotchi
|
|
|
244
244
|
end
|
|
245
245
|
# And the spawner's XDG dirs, absolute: a relative XDG_STATE_HOME or
|
|
246
246
|
# XDG_CONFIG_HOME would otherwise be read from the session's directory.
|
|
247
|
-
child_env
|
|
248
|
-
child_env["XDG_CONFIG_HOME"] = Paths.config_home unless ENV.fetch("XDG_CONFIG_HOME", "").to_s.strip.empty?
|
|
247
|
+
child_env.merge!(Paths.absolute_xdg)
|
|
249
248
|
# The spawner's environment must not reach the worker's guardrails (a
|
|
250
249
|
# parent agent starting or waking a session): Process.spawn unsets a
|
|
251
250
|
# key mapped to nil. config.yml no longer reads them; this keeps an
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "totals"
|
|
4
|
+
|
|
5
|
+
module Samagotchi
|
|
6
|
+
class SessionMetrics
|
|
7
|
+
# A run of consecutive turns on one model (their records' model_ref, the
|
|
8
|
+
# asked host:model): /stats and the web's ctx/cost tooltip split the
|
|
9
|
+
# session's tokens, cost and speed by these, in order, so A -> B -> A is
|
|
10
|
+
# three. Derived from the turn records as they are counted, never saved
|
|
11
|
+
# on their own. A record with no ref (a turn stopped before it asked a
|
|
12
|
+
# model) joins the segment before it; records with none before the first
|
|
13
|
+
# one that names a model (a session older than model_ref) make one
|
|
14
|
+
# leading segment with no ref.
|
|
15
|
+
#
|
|
16
|
+
# +prefill_tokens+ / +prefill_cost_estimate+: what the segment's first
|
|
17
|
+
# turn prefilled on its model after the switch (the records'
|
|
18
|
+
# switch_prefill_*), 0 / nil for none.
|
|
19
|
+
class ModelSegment < Data.define(:model_ref, :started_at, :ended_at, :turns, :totals, :prefill_tokens,
|
|
20
|
+
:prefill_cost_estimate)
|
|
21
|
+
# The most rows a snapshot sends: older ones fold into one.
|
|
22
|
+
CAP = 6
|
|
23
|
+
|
|
24
|
+
# A segment opened by +record+ (its first turn).
|
|
25
|
+
# @param record [Hash] a turn record (symbol keys)
|
|
26
|
+
# @return [ModelSegment]
|
|
27
|
+
def self.open(record)
|
|
28
|
+
ref = record[:model_ref].to_s
|
|
29
|
+
new(model_ref: ref.empty? ? nil : ref, started_at: record[:started_at], ended_at: nil, turns: 0,
|
|
30
|
+
totals: Totals.zero, prefill_tokens: 0, prefill_cost_estimate: nil).add(record)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The segment a snapshot shows for the running turn when it is on
|
|
34
|
+
# another model than the last segment's: no finished turn yet.
|
|
35
|
+
# @param ref [String]
|
|
36
|
+
# @param started_at [String, nil]
|
|
37
|
+
# @return [ModelSegment]
|
|
38
|
+
def self.empty(ref, started_at)
|
|
39
|
+
new(model_ref: ref, started_at: started_at, ended_at: nil, turns: 0, totals: Totals.zero, prefill_tokens: 0,
|
|
40
|
+
prefill_cost_estimate: nil)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Whether +record+ starts a new segment after this one: it names a
|
|
44
|
+
# model, and another one than this segment's.
|
|
45
|
+
def self.splits?(segment, record)
|
|
46
|
+
ref = record[:model_ref].to_s
|
|
47
|
+
segment.nil? || (!ref.empty? && segment.model_ref != ref)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# This segment with one more finished turn record.
|
|
51
|
+
# @return [ModelSegment]
|
|
52
|
+
def add(record)
|
|
53
|
+
tokens = record[:switch_prefill_tokens]
|
|
54
|
+
estimate = record[:switch_prefill_cost_estimate]
|
|
55
|
+
with(ended_at: record[:completed_at] || ended_at, turns: turns + 1, totals: totals.add(record),
|
|
56
|
+
prefill_tokens: prefill_tokens + (tokens.is_a?(Numeric) ? tokens : 0),
|
|
57
|
+
prefill_cost_estimate: estimate.is_a?(Numeric) ? prefill_cost_estimate.to_f + estimate : prefill_cost_estimate)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# This segment with the running turn (SessionMetrics::TurnState) in:
|
|
61
|
+
# its finished generations and the prefill it recorded so far; it
|
|
62
|
+
# runs on, so it has no end.
|
|
63
|
+
# @return [ModelSegment]
|
|
64
|
+
def with_running(turn)
|
|
65
|
+
estimate = turn.switch_prefill_cost_estimate
|
|
66
|
+
with(ended_at: nil, turns: turns + 1, totals: totals.with_running(turn),
|
|
67
|
+
prefill_tokens: prefill_tokens + turn.switch_prefill_tokens.to_i,
|
|
68
|
+
prefill_cost_estimate: estimate ? prefill_cost_estimate.to_f + estimate : prefill_cost_estimate)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The row a snapshot carries (JSON-ready). +ended_at+ nil: the segment
|
|
72
|
+
# runs now.
|
|
73
|
+
# @return [Hash]
|
|
74
|
+
def to_h
|
|
75
|
+
tokens = totals.tokens_block
|
|
76
|
+
{
|
|
77
|
+
model_ref: model_ref, started_at: started_at, ended_at: ended_at, turns: turns,
|
|
78
|
+
**tokens.slice(:prompt_sum, :completion_sum, :cached_sum, :cost_sum, :cost_estimate_sum, :avg_decode_tps,
|
|
79
|
+
:last_decode_tps, :tps_source),
|
|
80
|
+
prefill_tokens: prefill_tokens, prefill_cost_estimate: prefill_cost_estimate
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The rows of +segments+ a snapshot sends: none for fewer than 2 (one
|
|
85
|
+
# model: the plain totals say it all); over CAP, the oldest fold into
|
|
86
|
+
# one row with no ref that says how many it holds (folded_segments).
|
|
87
|
+
# @param segments [Array<ModelSegment>]
|
|
88
|
+
# @return [Array<Hash>]
|
|
89
|
+
def self.rows(segments)
|
|
90
|
+
return [] if segments.size < 2
|
|
91
|
+
return segments.map(&:to_h) if segments.size <= CAP
|
|
92
|
+
|
|
93
|
+
older = segments.first(segments.size - CAP + 1)
|
|
94
|
+
folded = older.drop(1).reduce(older.first.with(model_ref: nil)) do |sum, segment|
|
|
95
|
+
sum.merge(segment)
|
|
96
|
+
end
|
|
97
|
+
[folded.to_h.merge(folded_segments: older.size), *segments.last(CAP - 1).map(&:to_h)]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# This segment and a later one as one row (the folded older ones).
|
|
101
|
+
# @return [ModelSegment]
|
|
102
|
+
def merge(later)
|
|
103
|
+
estimate = [prefill_cost_estimate, later.prefill_cost_estimate].compact
|
|
104
|
+
with(ended_at: later.ended_at, turns: turns + later.turns, totals: totals.merge(later.totals),
|
|
105
|
+
prefill_tokens: prefill_tokens + later.prefill_tokens, prefill_cost_estimate: estimate.empty? ? nil : estimate.sum)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -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)
|
|
58
|
-
|
|
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:
|
|
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
|
|
459
|
-
tokens: tokens_block
|
|
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(
|
|
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
|
-
**(
|
|
471
|
-
tool_errors:
|
|
472
|
-
iterations_total: totals
|
|
473
|
-
gen_latency_ms:
|
|
474
|
-
retries: totals
|
|
475
|
-
cuts: totals
|
|
476
|
-
capped: totals
|
|
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
|
|
488
|
-
#
|
|
489
|
-
#
|
|
490
|
-
def
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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)
|
|
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 =
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
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
|
-
@
|
|
1099
|
-
tally_into(@
|
|
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
|
-
@
|
|
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
|