samagotchi 0.44.0 → 0.46.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 +120 -1
- data/docs/architecture.md +2 -1
- data/docs/cli.md +43 -12
- data/docs/configuration.md +102 -5
- data/docs/context.md +2 -1
- data/docs/internals/background-tasks.md +1 -0
- data/docs/internals/engine.md +1 -1
- data/docs/internals/llm-context-live.md +111 -0
- data/docs/plugins.md +4 -2
- data/docs/sessions.md +9 -5
- data/lib/samagotchi/bridge/turn_accumulator.rb +19 -0
- data/lib/samagotchi/bridge.rb +22 -10
- data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
- data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/commands/registry.rb +54 -9
- data/lib/samagotchi/config.rb +57 -4
- data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
- data/lib/samagotchi/engine.rb +47 -9
- data/lib/samagotchi/host_model.rb +116 -0
- data/lib/samagotchi/host_registry.rb +43 -12
- data/lib/samagotchi/kernel_loop.rb +2 -1
- data/lib/samagotchi/llm/chat_loop.rb +89 -7
- data/lib/samagotchi/llm/turn_settings.rb +5 -3
- data/lib/samagotchi/llm_context_strategy.rb +37 -0
- data/lib/samagotchi/model_catalog.rb +17 -12
- data/lib/samagotchi/model_notes.rb +8 -6
- data/lib/samagotchi/model_overlay.rb +3 -2
- data/lib/samagotchi/model_price.rb +54 -0
- data/lib/samagotchi/model_profile.rb +74 -3
- data/lib/samagotchi/plugin/context.rb +19 -7
- data/lib/samagotchi/process_group.rb +56 -9
- data/lib/samagotchi/recap_setup.rb +1 -1
- data/lib/samagotchi/self_report.rb +33 -3
- data/lib/samagotchi/session_commands.rb +47 -21
- data/lib/samagotchi/session_manager.rb +19 -1
- data/lib/samagotchi/session_metrics.rb +79 -11
- data/lib/samagotchi/sessions_command.rb +15 -5
- data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
- data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
- data/lib/samagotchi/terminal_ui/formatting.rb +45 -10
- data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
- data/lib/samagotchi/terminal_ui.rb +97 -12
- data/lib/samagotchi/tools/delegate_wait.rb +5 -4
- data/lib/samagotchi/tools/task_runtime.rb +11 -2
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +12 -11
- data/lib/samagotchi/web/public/app.js +30 -18
- data/lib/samagotchi/web/public/command_complete.js +3 -1
- data/lib/samagotchi/web/public/ctx.js +62 -22
- data/lib/samagotchi/web/public/format.js +7 -2
- data/lib/samagotchi/web/public/index.html +3 -1
- data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
- data/lib/samagotchi/web/public/model_pick.js +23 -3
- data/lib/samagotchi/web/public/model_picker.js +5 -3
- data/lib/samagotchi/web/public/stage_model.js +18 -4
- data/lib/samagotchi/web/public/stage_view.js +4 -3
- data/lib/samagotchi/web/public/turn_events.js +19 -6
- data/lib/samagotchi/web/public/turn_model.js +16 -0
- data/lib/samagotchi/web/public/turn_view.js +19 -0
- data/lib/samagotchi/web/session_hub.rb +9 -1
- data/lib/samagotchi/web/session_summary.rb +19 -2
- data/lib/samagotchi/worker.rb +172 -21
- data/lib/samagotchi/worker_inbound.rb +6 -5
- metadata +4 -1
|
@@ -7,6 +7,7 @@ require "fileutils"
|
|
|
7
7
|
require "securerandom"
|
|
8
8
|
require_relative "atomic_file"
|
|
9
9
|
require_relative "token_usage"
|
|
10
|
+
require_relative "model_price"
|
|
10
11
|
require_relative "session"
|
|
11
12
|
|
|
12
13
|
module Samagotchi
|
|
@@ -58,9 +59,11 @@ module Samagotchi
|
|
|
58
59
|
# memory_index: the memory indexes the session's prompt held
|
|
59
60
|
# ({ system: { tokens:, lines: }, project: … }, string keys as saved),
|
|
60
61
|
# nil when an older file has none.
|
|
62
|
+
# cost_estimate_sum: the estimated cost (hosts.<name>.models prices), 0
|
|
63
|
+
# in an older file.
|
|
61
64
|
SavedSummary = Data.define(:ctx_pct, :prompt_sum, :completion_sum, :cached_sum, :reasoning_sum, :cost_sum,
|
|
62
|
-
:memory_index) do
|
|
63
|
-
def initialize(memory_index: nil, **) = super
|
|
65
|
+
:cost_estimate_sum, :memory_index) do
|
|
66
|
+
def initialize(memory_index: nil, cost_estimate_sum: 0, **) = super
|
|
64
67
|
|
|
65
68
|
def tokens = to_h.except(:ctx_pct, :memory_index)
|
|
66
69
|
end
|
|
@@ -94,15 +97,21 @@ module Samagotchi
|
|
|
94
97
|
:gen_cache_write_max,
|
|
95
98
|
:gen_reasoning_max,
|
|
96
99
|
:gen_cost,
|
|
100
|
+
# The generation's configured price (ModelPrice, from
|
|
101
|
+
# :generation_started), what a generation without a reported cost
|
|
102
|
+
# (or a reported 0) is estimated from.
|
|
103
|
+
:gen_price,
|
|
97
104
|
:gen_decode_tps,
|
|
98
105
|
:gen_prefill_tps,
|
|
99
106
|
:gen_decode_ms,
|
|
100
107
|
:gen_first_chunk_at,
|
|
101
108
|
# The turn's generations, folded as each one ends: the count, the last
|
|
102
109
|
# one's prompt and completion, the sums, and which kinds of counts
|
|
103
|
-
# (server / estimate) they came from
|
|
110
|
+
# (server / estimate) they came from; prompt_max is the largest
|
|
111
|
+
# prompt (an LLM context edit can make a later one smaller).
|
|
104
112
|
:generations,
|
|
105
113
|
:prompt_last,
|
|
114
|
+
:prompt_max,
|
|
106
115
|
:prompt_sum,
|
|
107
116
|
:completion_last,
|
|
108
117
|
:completion_sum,
|
|
@@ -118,6 +127,8 @@ module Samagotchi
|
|
|
118
127
|
:reprefill_sum,
|
|
119
128
|
:reasoning_sum,
|
|
120
129
|
:cost_sum,
|
|
130
|
+
# The estimated cost of the generations priced from config (0 when none).
|
|
131
|
+
:cost_estimate_sum,
|
|
121
132
|
:decode_ms_sum,
|
|
122
133
|
:decode_tokens_sum,
|
|
123
134
|
:last_decode_tps,
|
|
@@ -141,38 +152,56 @@ module Samagotchi
|
|
|
141
152
|
# its saved analytics.json: a percentage, or nil when the file, the count
|
|
142
153
|
# or the window is missing (the session lists read it per row).
|
|
143
154
|
# @param session_dir [String]
|
|
155
|
+
# @param budget_tokens [Integer, Proc, nil] the session's LLM context
|
|
156
|
+
# budget; a positive one smaller than the window is what the fill is
|
|
157
|
+
# counted against (the live meter counts the same way,
|
|
158
|
+
# ContextStatus#counted_against). nil or 0: the window. A callable is
|
|
159
|
+
# resolved only when there is a context to count, so a caller can build
|
|
160
|
+
# a registry lazily.
|
|
144
161
|
# @return [Float, nil]
|
|
145
|
-
def self.saved_context_pct(session_dir)
|
|
146
|
-
saved_summary(session_dir)&.ctx_pct
|
|
162
|
+
def self.saved_context_pct(session_dir, budget_tokens: nil)
|
|
163
|
+
saved_summary(session_dir, budget_tokens: budget_tokens)&.ctx_pct
|
|
147
164
|
end
|
|
148
165
|
|
|
149
166
|
# The context's fill and the token totals from the session's saved
|
|
150
167
|
# analytics.json, read once; nil without a readable file.
|
|
151
168
|
# @param session_dir [String]
|
|
169
|
+
# @param budget_tokens [Integer, Proc, nil] see .saved_context_pct
|
|
152
170
|
# @return [SavedSummary, nil]
|
|
153
|
-
def self.saved_summary(session_dir)
|
|
171
|
+
def self.saved_summary(session_dir, budget_tokens: nil)
|
|
154
172
|
data = JSON.parse(File.read(File.join(session_dir, "analytics.json")))
|
|
155
173
|
return nil unless data.is_a?(Hash)
|
|
156
174
|
|
|
157
175
|
tokens = data["tokens"].is_a?(Hash) ? data["tokens"] : {}
|
|
158
176
|
count = ->(key) { tokens[key].is_a?(Numeric) ? tokens[key] : 0 }
|
|
159
|
-
SavedSummary.new(ctx_pct: saved_pct(data["context"]), prompt_sum: count.call("prompt_sum"),
|
|
177
|
+
SavedSummary.new(ctx_pct: saved_pct(data["context"], budget_tokens), prompt_sum: count.call("prompt_sum"),
|
|
160
178
|
completion_sum: count.call("completion_sum"), cached_sum: count.call("cached_sum"),
|
|
161
179
|
reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"),
|
|
180
|
+
cost_estimate_sum: count.call("cost_estimate_sum"),
|
|
162
181
|
memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil)
|
|
163
182
|
rescue JSON::ParserError, SystemCallError, TypeError
|
|
164
183
|
nil
|
|
165
184
|
end
|
|
166
185
|
|
|
167
|
-
def self.saved_pct(context)
|
|
186
|
+
def self.saved_pct(context, budget_tokens)
|
|
168
187
|
return nil unless context.is_a?(Hash)
|
|
169
188
|
|
|
170
189
|
used = context["used_tokens"]
|
|
171
190
|
window = context["window_tokens"]
|
|
172
191
|
return nil unless used.is_a?(Numeric) && window.is_a?(Numeric) && window.positive?
|
|
173
192
|
|
|
174
|
-
used * 100.0 / window
|
|
193
|
+
used * 100.0 / counted_against(budget_tokens, window)
|
|
175
194
|
end
|
|
195
|
+
|
|
196
|
+
# What the saved fill counts against: the window, or the budget when one
|
|
197
|
+
# is set and smaller (ContextStatus#counted_against, the live meter's
|
|
198
|
+
# rule). The budget is resolved here, not by the caller, so a caller
|
|
199
|
+
# that builds one lazily doesn't build it for a row with no context.
|
|
200
|
+
def self.counted_against(budget_tokens, window_tokens)
|
|
201
|
+
budget = budget_tokens.respond_to?(:call) ? budget_tokens.call : budget_tokens
|
|
202
|
+
budget.is_a?(Numeric) && budget.positive? ? [budget, window_tokens].min : window_tokens
|
|
203
|
+
end
|
|
204
|
+
private_class_method :counted_against
|
|
176
205
|
private_class_method :saved_pct
|
|
177
206
|
|
|
178
207
|
# One turn's tool records from the session's saved analytics.json, in
|
|
@@ -192,6 +221,22 @@ module Samagotchi
|
|
|
192
221
|
[]
|
|
193
222
|
end
|
|
194
223
|
|
|
224
|
+
# One turn's saved record from the session's analytics.json, string-keyed
|
|
225
|
+
# as saved: what a join replays the turn's failure (or cancel) line from.
|
|
226
|
+
# nil without a turn id (an older session's prompt), the file, records,
|
|
227
|
+
# or a record for the turn.
|
|
228
|
+
# @param session_dir [String]
|
|
229
|
+
# @param turn_id [String, nil]
|
|
230
|
+
# @return [Hash, nil]
|
|
231
|
+
def self.saved_turn_record(session_dir, turn_id)
|
|
232
|
+
return nil if turn_id.to_s.empty?
|
|
233
|
+
|
|
234
|
+
records = JSON.parse(File.read(File.join(session_dir, "analytics.json")))["turn_records"]
|
|
235
|
+
Array(records).find { |r| r.is_a?(Hash) && r["id"] == turn_id }
|
|
236
|
+
rescue JSON::ParserError, SystemCallError, TypeError
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
|
|
195
240
|
# "ctx 12%" for the session lists, "" when unknown.
|
|
196
241
|
# @param pct [Float, nil]
|
|
197
242
|
# @return [String]
|
|
@@ -289,6 +334,7 @@ module Samagotchi
|
|
|
289
334
|
@turn.gen_started_at = monotonic_time
|
|
290
335
|
@turn.generations += 1
|
|
291
336
|
reset_generation_tokens
|
|
337
|
+
@turn.gen_price = generation_price(event[:price])
|
|
292
338
|
@turn.gen_open = true
|
|
293
339
|
end
|
|
294
340
|
end
|
|
@@ -424,6 +470,7 @@ module Samagotchi
|
|
|
424
470
|
reprefill_sum: add.call(:reprefill_sum),
|
|
425
471
|
reasoning_sum: add.call(:reasoning_sum),
|
|
426
472
|
cost_sum: add.call(:cost_sum),
|
|
473
|
+
cost_estimate_sum: add.call(:cost_estimate_sum),
|
|
427
474
|
decode_ms_sum: decode_ms.round,
|
|
428
475
|
decode_tokens_sum: decode_tokens,
|
|
429
476
|
avg_decode_tps: decode_ms.positive? ? (decode_tokens * 1000.0 / decode_ms).round(1) : nil,
|
|
@@ -475,6 +522,7 @@ module Samagotchi
|
|
|
475
522
|
gen_reasoning_max: 0,
|
|
476
523
|
generations: 0,
|
|
477
524
|
prompt_last: nil,
|
|
525
|
+
prompt_max: nil,
|
|
478
526
|
prompt_sum: 0,
|
|
479
527
|
completion_last: 0,
|
|
480
528
|
completion_sum: 0,
|
|
@@ -484,6 +532,7 @@ module Samagotchi
|
|
|
484
532
|
reprefill_sum: 0,
|
|
485
533
|
reasoning_sum: 0,
|
|
486
534
|
cost_sum: nil,
|
|
535
|
+
cost_estimate_sum: 0,
|
|
487
536
|
decode_ms_sum: 0,
|
|
488
537
|
decode_tokens_sum: 0,
|
|
489
538
|
retries: 0,
|
|
@@ -594,6 +643,7 @@ module Samagotchi
|
|
|
594
643
|
if turn.gen_had_server
|
|
595
644
|
completion = turn.gen_completion_max
|
|
596
645
|
turn.prompt_last = turn.gen_prompt_max
|
|
646
|
+
turn.prompt_max = [turn.prompt_max.to_i, turn.gen_prompt_max].max
|
|
597
647
|
turn.prompt_sum += turn.gen_prompt_max
|
|
598
648
|
turn.token_sources |= ["server"]
|
|
599
649
|
speed = close_server_report(turn, completion)
|
|
@@ -623,6 +673,12 @@ module Samagotchi
|
|
|
623
673
|
turn.cache_write_sum += turn.gen_cache_write_max
|
|
624
674
|
turn.reasoning_sum += turn.gen_reasoning_max
|
|
625
675
|
turn.cost_sum = turn.cost_sum.to_f + turn.gen_cost if turn.gen_cost
|
|
676
|
+
# A reported cost wins; a reported 0 with a price is taken as no report
|
|
677
|
+
# (a gateway that doesn't bill per call); the 0 stays in cost.
|
|
678
|
+
if turn.gen_price && (turn.gen_cost.nil? || turn.gen_cost.zero?)
|
|
679
|
+
turn.cost_estimate_sum += turn.gen_price.cost(prompt_tokens: turn.gen_prompt_max, cached_tokens: turn.gen_cached_max,
|
|
680
|
+
cache_write_tokens: turn.gen_cache_write_max, completion_tokens: completion)
|
|
681
|
+
end
|
|
626
682
|
turn.last_prefill_tps = turn.gen_prefill_tps.round(1) if turn.gen_prefill_tps
|
|
627
683
|
speed = generation_speed(turn, completion)
|
|
628
684
|
return unless speed
|
|
@@ -652,7 +708,9 @@ module Samagotchi
|
|
|
652
708
|
[tps, decode_ms, source]
|
|
653
709
|
end
|
|
654
710
|
|
|
655
|
-
#
|
|
711
|
+
# The open generation's counts start over (a new generation, or a retry
|
|
712
|
+
# that streams from the start). Its price stays: generation_started sets
|
|
713
|
+
# it, and a retry sends none. Caller holds the mutex.
|
|
656
714
|
def reset_generation_tokens
|
|
657
715
|
@turn.gen_completion_max = 0
|
|
658
716
|
@turn.gen_prompt_max = 0
|
|
@@ -675,6 +733,7 @@ module Samagotchi
|
|
|
675
733
|
model: turn.model,
|
|
676
734
|
generations: turn.generations,
|
|
677
735
|
prompt_tokens: turn.prompt_last,
|
|
736
|
+
prompt_tokens_max: turn.prompt_max,
|
|
678
737
|
prompt_tokens_sum: turn.prompt_sum,
|
|
679
738
|
completion_tokens: turn.completion_sum,
|
|
680
739
|
context_used_tokens: turn.prompt_last && (turn.prompt_last + turn.completion_last),
|
|
@@ -705,6 +764,7 @@ module Samagotchi
|
|
|
705
764
|
fields[:cache_write_tokens_sum] = turn.cache_write_sum if turn.cache_write_sum.positive?
|
|
706
765
|
fields[:reprefill_tokens_sum] = turn.reprefill_sum if turn.reprefill_sum.positive?
|
|
707
766
|
fields.merge!(cost: turn.cost_sum, cost_source: "reported") if turn.cost_sum
|
|
767
|
+
fields[:cost_estimate] = turn.cost_estimate_sum if turn.cost_estimate_sum.positive?
|
|
708
768
|
fields
|
|
709
769
|
end
|
|
710
770
|
|
|
@@ -829,6 +889,13 @@ module Samagotchi
|
|
|
829
889
|
event_content.is_a?(String) ? event_content : ""
|
|
830
890
|
end
|
|
831
891
|
|
|
892
|
+
# The :generation_started event's price (ModelPrice#to_h), nil for none.
|
|
893
|
+
def generation_price(hash)
|
|
894
|
+
hash.is_a?(Hash) ? ModelPrice.new(**hash.transform_keys(&:to_sym)) : nil
|
|
895
|
+
rescue ArgumentError
|
|
896
|
+
nil
|
|
897
|
+
end
|
|
898
|
+
|
|
832
899
|
def monotonic_time
|
|
833
900
|
@clock.call
|
|
834
901
|
end
|
|
@@ -948,7 +1015,7 @@ module Samagotchi
|
|
|
948
1015
|
@totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
|
|
949
1016
|
gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
|
|
950
1017
|
reprefill_sum: 0, reasoning_sum: 0,
|
|
951
|
-
cost_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
|
|
1018
|
+
cost_sum: 0, cost_estimate_sum: 0, decode_ms_sum: 0, decode_tokens_sum: 0, last_decode_tps: nil,
|
|
952
1019
|
last_prefill_tps: nil, tps_source: nil }
|
|
953
1020
|
end
|
|
954
1021
|
|
|
@@ -967,6 +1034,7 @@ module Samagotchi
|
|
|
967
1034
|
totals[:reprefill_sum] += number(record[:reprefill_tokens_sum])
|
|
968
1035
|
totals[:reasoning_sum] += number(record[:reasoning_tokens])
|
|
969
1036
|
totals[:cost_sum] += number(record[:cost])
|
|
1037
|
+
totals[:cost_estimate_sum] += number(record[:cost_estimate])
|
|
970
1038
|
totals[:decode_ms_sum] += number(record[:decode_ms])
|
|
971
1039
|
totals[:decode_tokens_sum] += number(record[:decode_tokens])
|
|
972
1040
|
# The newest speeds a record has (an older record has none).
|
|
@@ -226,14 +226,24 @@ module Samagotchi
|
|
|
226
226
|
recap = Samagotchi::RecapStore.preview(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
|
|
227
227
|
(recap || Samagotchi::SessionManager.summary_text(s))[0, 60]
|
|
228
228
|
end
|
|
229
|
+
# How full the context was after the last turn: "ctx 12%", counted
|
|
230
|
+
# against the session's llm_context budget when one is set and smaller
|
|
231
|
+
# than the window, as the live meter counts it (one registry for the
|
|
232
|
+
# whole table, built when the first row has a saved context). Blank when
|
|
233
|
+
# unknown.
|
|
234
|
+
budgets = nil
|
|
235
|
+
ctx = lambda do |s|
|
|
236
|
+
Samagotchi::SessionMetrics.context_label(
|
|
237
|
+
Samagotchi::SessionMetrics.saved_context_pct(
|
|
238
|
+
Samagotchi::Session.session_dir(s.id, state_dir: state_dir),
|
|
239
|
+
budget_tokens: -> { Samagotchi::LLMContextStrategy.session_budget(s, registry: budgets ||= Samagotchi::HostRegistry.new) }
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
end
|
|
229
243
|
# A delegated session points at its parent: ↳ <parent's short id>.
|
|
230
244
|
row = lambda do |s|
|
|
231
245
|
flag = session_flag(scratch: s.scratch, test_run: s.test_run, archived: s.archived, stopped_by: s.stopped_by)
|
|
232
246
|
child = s.parent_id ? " ↳ #{s.parent_id[0, 8]}" : ""
|
|
233
|
-
# How full the context was after the last turn: "ctx 12%", blank when unknown.
|
|
234
|
-
ctx = Samagotchi::SessionMetrics.context_label(
|
|
235
|
-
Samagotchi::SessionMetrics.saved_context_pct(Samagotchi::Session.session_dir(s.id, state_dir: state_dir))
|
|
236
|
-
)
|
|
237
247
|
# A question waits for an answer (chi answer, the web, chi --attach).
|
|
238
248
|
live = s.pending_question && Samagotchi::SessionManager.worker_live?(s.id, state_dir: state_dir)
|
|
239
249
|
waiting = s.waiting_question(live: !!live)
|
|
@@ -241,7 +251,7 @@ module Samagotchi
|
|
|
241
251
|
elsif waiting then "waiting"
|
|
242
252
|
else s.status
|
|
243
253
|
end
|
|
244
|
-
"#{s.id} #{status.ljust(8)} #{ctx.ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
|
|
254
|
+
"#{s.id} #{status.ljust(8)} #{ctx.call(s).ljust(8)} #{s.updated_at} #{list_text.call(s)}#{flag}#{child}"
|
|
245
255
|
end
|
|
246
256
|
if project
|
|
247
257
|
sessions.each { |s| @stdout.puts row.call(s) }
|
|
@@ -126,6 +126,10 @@ module Samagotchi
|
|
|
126
126
|
@early_lines = []
|
|
127
127
|
@question = nil
|
|
128
128
|
@answered_ids = Set.new
|
|
129
|
+
# Commands queued for after the running turn, any UI's: a /archive
|
|
130
|
+
# typed during the turn waits for them too (#archive_due?).
|
|
131
|
+
@queued_ids = Set.new
|
|
132
|
+
@archive_after_turn = false
|
|
129
133
|
@reader = nil
|
|
130
134
|
@first_prompt = first_prompt
|
|
131
135
|
@first_commands = Array(first_command)
|
|
@@ -188,6 +192,7 @@ module Samagotchi
|
|
|
188
192
|
when :event
|
|
189
193
|
ended = safely_handle(payload)
|
|
190
194
|
return ended if %i[closed failed unanswered].include?(ended)
|
|
195
|
+
return archive_now if archive_due?
|
|
191
196
|
|
|
192
197
|
ended = take_early_lines
|
|
193
198
|
return ended if ended
|
|
@@ -264,9 +269,11 @@ module Samagotchi
|
|
|
264
269
|
when :answer_display then @display_pending = false
|
|
265
270
|
when :context_status, :used_memories_updated then @status.take_event(event)
|
|
266
271
|
# Another client's anytime command: its line now, before the cards
|
|
267
|
-
# it shows (its command_ran comes when it's done).
|
|
272
|
+
# it shows (its command_ran comes when it's done). One queued for
|
|
273
|
+
# after the turn says so now.
|
|
268
274
|
when :command_queued
|
|
269
275
|
@screen.commit(prompt_line(event[:client_id], event[:line])) if event[:anytime] && !own?(event[:client_id]) && !event[:card]
|
|
276
|
+
command_waits(event) if event[:waits] && !event[:card]
|
|
270
277
|
when :command_ran
|
|
271
278
|
command_ran(event)
|
|
272
279
|
return first_command_ran(event) if @first_command_id && event[:command_id] == @first_command_id
|
|
@@ -379,6 +386,7 @@ module Samagotchi
|
|
|
379
386
|
local = command_registry.lookup_local(text)&.id
|
|
380
387
|
return submit(nil) if local == :detach
|
|
381
388
|
return SessionCommands.delete_on_exit?(text) ? exit_and_delete : exit_worker if local == :exit
|
|
389
|
+
return archive_after_turn if local == :archive && (@running || !@queued_ids.empty?)
|
|
382
390
|
return exit_and_archive if local == :archive
|
|
383
391
|
|
|
384
392
|
if @continue_offer
|
|
@@ -446,6 +454,24 @@ module Samagotchi
|
|
|
446
454
|
detach("Detached; the worker is stopping, but session #{id} was not deleted (#{e.message}): chi sessions delete #{id}")
|
|
447
455
|
end
|
|
448
456
|
|
|
457
|
+
# /archive during a turn (D2): it waits for the turn's end and the
|
|
458
|
+
# commands queued behind it (#archive_due?), then runs.
|
|
459
|
+
# @return [nil]
|
|
460
|
+
def archive_after_turn
|
|
461
|
+
@archive_after_turn = true
|
|
462
|
+
@screen.commit("(archives after this turn)")
|
|
463
|
+
nil
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def archive_due? = @archive_after_turn && !@running && @queued_ids.empty?
|
|
467
|
+
|
|
468
|
+
# @return [Symbol] :detached
|
|
469
|
+
def archive_now
|
|
470
|
+
@archive_after_turn = false
|
|
471
|
+
exit_and_archive
|
|
472
|
+
:detached
|
|
473
|
+
end
|
|
474
|
+
|
|
449
475
|
# /archive: the worker is asked to exit as for /exit, then the session
|
|
450
476
|
# is archived. A worker staying up for another UI (or reminders) is
|
|
451
477
|
# stopped by the archive, as the web's archive stops it; a turn
|
|
@@ -546,13 +572,36 @@ module Samagotchi
|
|
|
546
572
|
"worker unreachable: #{head.downcase}"
|
|
547
573
|
end
|
|
548
574
|
|
|
575
|
+
QUEUED_NOTE = "(queued: runs after this turn)"
|
|
576
|
+
|
|
577
|
+
# A command queued for after the running turn (command_queued with
|
|
578
|
+
# waits): its line now (another client's) and a note; its command_ran
|
|
579
|
+
# has no line again (queued: true).
|
|
580
|
+
def command_waits(event)
|
|
581
|
+
@queued_ids << event[:command_id]
|
|
582
|
+
if own?(event[:client_id])
|
|
583
|
+
first = event[:command_id] == @first_command_id
|
|
584
|
+
return @screen.commit(first ? first_command_queued : QUEUED_NOTE)
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
@screen.commit("#{prompt_line(event[:client_id], event[:line])} #{QUEUED_NOTE}")
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def first_command_queued
|
|
591
|
+
flag = @first_command_line.to_s.start_with?("/model") ? "--model" : "--llm-context"
|
|
592
|
+
"(#{flag} queued: applies after the running turn)"
|
|
593
|
+
end
|
|
594
|
+
|
|
549
595
|
def command_ran(event)
|
|
550
596
|
@open_ids.delete(event[:command_id])
|
|
551
|
-
@
|
|
597
|
+
@queued_ids.delete(event[:command_id])
|
|
598
|
+
unless own?(event[:client_id]) || event[:anytime] || event[:card] || event[:queued]
|
|
599
|
+
@screen.commit(prompt_line(event[:client_id], event[:line]))
|
|
600
|
+
end
|
|
552
601
|
output = event[:output].to_s
|
|
553
602
|
if output.empty?
|
|
554
603
|
nil
|
|
555
|
-
elsif event[:status]
|
|
604
|
+
elsif %w[busy dropped].include?(event[:status]) || PromptHistory.shell_line?(event[:line])
|
|
556
605
|
@screen.commit(output)
|
|
557
606
|
put_back_busy_command(event) if event[:status] == "busy"
|
|
558
607
|
else
|
|
@@ -992,6 +1041,14 @@ module Samagotchi
|
|
|
992
1041
|
@shown_enqueued << entry[:enqueued_id]
|
|
993
1042
|
@screen.commit("queued #{prompt_line(entry[:client_id], entry[:prompt])}")
|
|
994
1043
|
end
|
|
1044
|
+
# Commands waiting for the turn's end (their command_ran prints no
|
|
1045
|
+
# line); a resync prints only the ones it hadn't shown.
|
|
1046
|
+
shown = @queued_ids.dup
|
|
1047
|
+
@queued_ids.clear
|
|
1048
|
+
Array(snapshot[:queued_commands]).each do |entry|
|
|
1049
|
+
@queued_ids << entry[:command_id]
|
|
1050
|
+
@screen.commit("queued #{prompt_line(entry[:client_id], entry[:line])}") unless shown.include?(entry[:command_id])
|
|
1051
|
+
end
|
|
995
1052
|
return send_first_command unless @first_commands.empty?
|
|
996
1053
|
|
|
997
1054
|
send_first_prompt
|
|
@@ -1076,10 +1133,18 @@ module Samagotchi
|
|
|
1076
1133
|
Array(exchange[last_user][:images]).each { |ref| @screen.commit(format_image_line(ref)) }
|
|
1077
1134
|
from = messages.index { |m| m.equal?(exchange[last_user]) }
|
|
1078
1135
|
render_join_steps(messages, from, durations: join_tool_records(exchange[last_user]))
|
|
1136
|
+
failed = join_failed_record(exchange[last_user])
|
|
1079
1137
|
# A turn that ended with no answer: its notice, as live.
|
|
1080
1138
|
empty = messages.drop(from + 1).filter_map { |m| TurnNote.empty_answer(m) }.last
|
|
1081
1139
|
if empty
|
|
1082
1140
|
@screen.commit(format_empty_answer_line(empty[:retries] || empty["retries"]))
|
|
1141
|
+
elsif failed
|
|
1142
|
+
# A failed turn whose steps stayed: the line the live view left,
|
|
1143
|
+
# and no step text as if it were the answer (the web keeps a
|
|
1144
|
+
# failed turn's texts as steps too).
|
|
1145
|
+
failure = failed["failure"]
|
|
1146
|
+
@screen.commit(@view.turn_failed_line(failure["summary"] || failure["message"], failed["duration_ms"]))
|
|
1147
|
+
@screen.commit(@view.turn_end_hint(ROLLBACK_HINT)) if failure["kept_steps"]
|
|
1083
1148
|
else
|
|
1084
1149
|
# The saved answer is raw: the latest keeps its thinking, and one may
|
|
1085
1150
|
# be only a tool call.
|
|
@@ -1119,6 +1184,23 @@ module Samagotchi
|
|
|
1119
1184
|
[]
|
|
1120
1185
|
end
|
|
1121
1186
|
|
|
1187
|
+
# The turn's saved record when it failed with the fields its line is
|
|
1188
|
+
# made of (SessionMetrics#failure_fields), else nil: a completed,
|
|
1189
|
+
# canceled or older turn — or one saved before the failure field —
|
|
1190
|
+
# draws its answer as before.
|
|
1191
|
+
def join_failed_record(prompt)
|
|
1192
|
+
record = SessionMetrics.saved_turn_record(Session.session_dir(@client.session_id),
|
|
1193
|
+
prompt[:turn_id] || prompt["turn_id"])
|
|
1194
|
+
return nil unless record && record["status"] == "failed" && record["failure"].is_a?(Hash)
|
|
1195
|
+
|
|
1196
|
+
failure = record["failure"]
|
|
1197
|
+
return nil if failure["summary"].to_s.empty? && failure["message"].to_s.empty?
|
|
1198
|
+
|
|
1199
|
+
record
|
|
1200
|
+
rescue Session::InvalidId
|
|
1201
|
+
nil
|
|
1202
|
+
end
|
|
1203
|
+
|
|
1122
1204
|
# The duration of +part+'s call: the next record of its tool, in call
|
|
1123
1205
|
# order, consumed with the ones it skipped. By tool and order, not by
|
|
1124
1206
|
# iteration: an empty retry spends an iteration that left no message.
|
|
@@ -93,6 +93,8 @@ module Samagotchi
|
|
|
93
93
|
def generation_feedback_retrying(event)
|
|
94
94
|
@lock.synchronize do
|
|
95
95
|
@retry = format_generation_retry_line(event)
|
|
96
|
+
# A dropped stream's step asked again: its sentence so far is void.
|
|
97
|
+
@line.reset if event[:restarted]
|
|
96
98
|
# The retry streams from the start: wait for its first token again.
|
|
97
99
|
@waiting_since = @clock.call if @thinking
|
|
98
100
|
redraw_status
|
|
@@ -103,6 +103,9 @@ module Samagotchi
|
|
|
103
103
|
return "retrying (attempt #{event[:attempt]})" unless event[:max_retries]
|
|
104
104
|
|
|
105
105
|
text = "retrying (#{event[:attempt]}/#{event[:max_retries]} in #{format("%.1f", event[:next_delay].to_f)}s)"
|
|
106
|
+
# restarted: a dropped stream's step asked again (the chat loop's).
|
|
107
|
+
return "#{text}: stream dropped" if event[:restarted]
|
|
108
|
+
|
|
106
109
|
event[:error_class].to_s.empty? ? text : "#{text}: #{event[:error_class]}"
|
|
107
110
|
end
|
|
108
111
|
|
|
@@ -342,13 +345,25 @@ module Samagotchi
|
|
|
342
345
|
"(#{scopes.map { |scope, tokens| "#{scope} #{count.call(tokens)}" }.join(", ")})"
|
|
343
346
|
end
|
|
344
347
|
|
|
345
|
-
# "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); ""
|
|
346
|
-
# none. As ctx.js
|
|
347
|
-
|
|
348
|
+
# "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); "~$0.12"
|
|
349
|
+
# for an estimate (hosts.<name>.models prices); "" for none. As ctx.js
|
|
350
|
+
# costText.
|
|
351
|
+
def cost_text(cost, estimate: false)
|
|
348
352
|
value = cost.to_f
|
|
349
353
|
return "" unless value.positive?
|
|
350
354
|
|
|
351
|
-
value >= 0.01 ? format("$%.2f", value) : format("$%.4f", value)
|
|
355
|
+
"#{"~" if estimate}#{value >= 0.01 ? format("$%.2f", value) : format("$%.4f", value)}"
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# /stats' cost: the reported part and the estimated one, each when present.
|
|
359
|
+
def stats_cost_text(tokens)
|
|
360
|
+
reported = cost_text(tokens[:cost_sum])
|
|
361
|
+
estimated = cost_text(tokens[:cost_estimate_sum], estimate: true)
|
|
362
|
+
return nil if reported.empty? && estimated.empty?
|
|
363
|
+
return "#{reported} (this session only)" if estimated.empty?
|
|
364
|
+
|
|
365
|
+
estimate = "#{estimated} from hosts.<name>.models prices"
|
|
366
|
+
"#{reported.empty? ? estimate : "#{reported} reported + #{estimate}"} (this session only)"
|
|
352
367
|
end
|
|
353
368
|
|
|
354
369
|
private
|
|
@@ -411,9 +426,9 @@ module Samagotchi
|
|
|
411
426
|
|
|
412
427
|
# +served+ / +served_for+: the model the server said it served for the
|
|
413
428
|
# name asked (see Engine#served_model); shown first when it's another
|
|
414
|
-
# model.
|
|
415
|
-
def status_model_text(model, default_model, served: nil, served_for: nil)
|
|
416
|
-
if ServedModel.differs?(served_for, served)
|
|
429
|
+
# model, unless +expected+ (the host's served: names it).
|
|
430
|
+
def status_model_text(model, default_model, served: nil, served_for: nil, expected: false)
|
|
431
|
+
if !expected && ServedModel.differs?(served_for, served)
|
|
417
432
|
asked = model.to_s.length > STATUS_ASKED_MAX ? "#{model.to_s[0, STATUS_ASKED_MAX - 1]}…" : model
|
|
418
433
|
return "model=#{served} (served; asked #{asked})"
|
|
419
434
|
end
|
|
@@ -477,7 +492,8 @@ module Samagotchi
|
|
|
477
492
|
"#{token_breakdown_text(tokens)}"
|
|
478
493
|
speed = stats_speed_text(tokens)
|
|
479
494
|
lines << "speed: #{speed}" if speed
|
|
480
|
-
|
|
495
|
+
cost = stats_cost_text(tokens)
|
|
496
|
+
lines << "cost: #{cost}" if cost
|
|
481
497
|
lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
|
|
482
498
|
lines << "cancellations: #{snapshot[:cancellations]}"
|
|
483
499
|
lines << "retries: #{snapshot[:retries]}"
|
|
@@ -486,7 +502,7 @@ module Samagotchi
|
|
|
486
502
|
lines << "output cap hits: #{snapshot[:capped].to_i}"
|
|
487
503
|
context = snapshot[:context] || {}
|
|
488
504
|
if context[:used_tokens]
|
|
489
|
-
pct = context
|
|
505
|
+
pct = context_pct_text(context, snapshot[:llm_context])
|
|
490
506
|
lines << "context used: #{context[:used_tokens]} tokens#{pct}"
|
|
491
507
|
end
|
|
492
508
|
lines << "context window: #{context[:window_tokens]} tokens (#{context[:window_source]})" if context[:window_tokens]
|
|
@@ -500,12 +516,31 @@ module Samagotchi
|
|
|
500
516
|
lines << "llm context: #{llm_context}" if llm_context
|
|
501
517
|
if snapshot[:served_model]
|
|
502
518
|
asked = snapshot[:served_model_for]
|
|
503
|
-
|
|
519
|
+
differs = !snapshot[:served_expected_by] && ServedModel.differs?(asked, snapshot[:served_model])
|
|
520
|
+
note = differs ? " (asked for #{asked})" : ""
|
|
504
521
|
lines << "served model: #{snapshot[:served_model]}#{note}"
|
|
505
522
|
end
|
|
506
523
|
lines.join("\n")
|
|
507
524
|
end
|
|
508
525
|
|
|
526
|
+
# " (25.0% of the 64000 budget)" when the session's llm_context budget
|
|
527
|
+
# is set and smaller than the window, else " (12.5%)": the live meter
|
|
528
|
+
# counts against the smaller of the two (ContextStatus#counted_against),
|
|
529
|
+
# and so does this. The summary is Explained#summary (symbol or string
|
|
530
|
+
# keys: the attached TUI's come as JSON); "" when there is no window.
|
|
531
|
+
def context_pct_text(context, llm_context)
|
|
532
|
+
window = context[:window_tokens].to_i
|
|
533
|
+
return "" unless window.positive?
|
|
534
|
+
|
|
535
|
+
summary = llm_context.is_a?(Hash) ? llm_context : {}
|
|
536
|
+
budget = summary[:budget_tokens] || summary["budget_tokens"]
|
|
537
|
+
budget = nil unless budget.is_a?(Numeric) && budget.positive?
|
|
538
|
+
used = context[:used_tokens] * 100.0
|
|
539
|
+
return format(" (%.1f%%)", used / window) unless budget && budget < window
|
|
540
|
+
|
|
541
|
+
format(" (%.1f%% of the %d budget)", used / budget, budget)
|
|
542
|
+
end
|
|
543
|
+
|
|
509
544
|
# "stale,forget (the session); apply turn_end (models: x); budget
|
|
510
545
|
# 64000 (the session)" for /stats (Explained#summary; symbol or
|
|
511
546
|
# string keys, the attached TUI's come as JSON); nil without one.
|
|
@@ -17,8 +17,9 @@ module Samagotchi
|
|
|
17
17
|
# What the row shows:
|
|
18
18
|
# model, default_model the model in use, and the config's default
|
|
19
19
|
# (named beside another model)
|
|
20
|
-
# served [served, asked]: what the server said it
|
|
21
|
-
# served for the name asked (ServedModel)
|
|
20
|
+
# served [served, asked, expected]: what the server said it
|
|
21
|
+
# served for the name asked (ServedModel), and
|
|
22
|
+
# the host whose served: expects it (or nil)
|
|
22
23
|
# parent_id the session that delegated this one
|
|
23
24
|
# children its delegates' ChildrenStatus::Counts, or
|
|
24
25
|
# nil (the segment shows running and waiting)
|
|
@@ -61,7 +62,7 @@ module Samagotchi
|
|
|
61
62
|
# @param default_model [String, nil] the config's default, named beside
|
|
62
63
|
# another model
|
|
63
64
|
def take_state(state, default_model: nil)
|
|
64
|
-
fields = { served: state[:served_model] ? [state[:served_model], state[:served_model_for]] : nil }
|
|
65
|
+
fields = { served: state[:served_model] ? [state[:served_model], state[:served_model_for], state[:served_expected_by]] : nil }
|
|
65
66
|
fields.merge!(model: state[:model_name], default_model: default_model) if state[:model_name]
|
|
66
67
|
STATE_LISTS.each { |field, key| fields[field] = Array(state[key]) if state.key?(key) }
|
|
67
68
|
fields[:parent_id] = state[:parent_id] if state.key?(:parent_id)
|
|
@@ -82,7 +83,7 @@ module Samagotchi
|
|
|
82
83
|
when :context_status then update(context: { est_pct: event.dig(:usage, :estimated_pct), bucket: event[:bucket] })
|
|
83
84
|
when :used_memories_updated then update(used_memories: Array(event[:used_memory_names]))
|
|
84
85
|
when :generation_completed
|
|
85
|
-
update(served: [event[:served_model], event[:requested_model]]) if event[:served_model]
|
|
86
|
+
update(served: [event[:served_model], event[:requested_model], event[:served_expected_by]]) if event[:served_model]
|
|
86
87
|
end
|
|
87
88
|
end
|
|
88
89
|
|
|
@@ -104,10 +105,15 @@ module Samagotchi
|
|
|
104
105
|
|
|
105
106
|
# @return [Array<String>] the row cut to +width+ (none with nothing to say)
|
|
106
107
|
def rows(width)
|
|
107
|
-
served, served_for = @values[:served]
|
|
108
|
+
served, served_for, expected = @values[:served]
|
|
108
109
|
model = @values[:model]
|
|
109
110
|
parent = @values[:parent_id]
|
|
110
|
-
|
|
111
|
+
model_text = ""
|
|
112
|
+
if model
|
|
113
|
+
model_text = status_model_text(model, @values[:default_model], served: served, served_for: served_for,
|
|
114
|
+
expected: expected.is_a?(String))
|
|
115
|
+
end
|
|
116
|
+
segments = [model_text,
|
|
111
117
|
parent ? "↳ #{parent.to_s[0, 8]}" : "",
|
|
112
118
|
children_text(@values[:children]),
|
|
113
119
|
status_context_text(estimate: @values[:context]),
|