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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +120 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +43 -12
  5. data/docs/configuration.md +102 -5
  6. data/docs/context.md +2 -1
  7. data/docs/internals/background-tasks.md +1 -0
  8. data/docs/internals/engine.md +1 -1
  9. data/docs/internals/llm-context-live.md +111 -0
  10. data/docs/plugins.md +4 -2
  11. data/docs/sessions.md +9 -5
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +19 -0
  13. data/lib/samagotchi/bridge.rb +22 -10
  14. data/lib/samagotchi/bundles/github-pr/manifest.yml +3 -3
  15. data/lib/samagotchi/bundles/github-pr/plugin.rb +22 -4
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  18. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  19. data/lib/samagotchi/commands/registry.rb +54 -9
  20. data/lib/samagotchi/config.rb +57 -4
  21. data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
  22. data/lib/samagotchi/engine.rb +47 -9
  23. data/lib/samagotchi/host_model.rb +116 -0
  24. data/lib/samagotchi/host_registry.rb +43 -12
  25. data/lib/samagotchi/kernel_loop.rb +2 -1
  26. data/lib/samagotchi/llm/chat_loop.rb +89 -7
  27. data/lib/samagotchi/llm/turn_settings.rb +5 -3
  28. data/lib/samagotchi/llm_context_strategy.rb +37 -0
  29. data/lib/samagotchi/model_catalog.rb +17 -12
  30. data/lib/samagotchi/model_notes.rb +8 -6
  31. data/lib/samagotchi/model_overlay.rb +3 -2
  32. data/lib/samagotchi/model_price.rb +54 -0
  33. data/lib/samagotchi/model_profile.rb +74 -3
  34. data/lib/samagotchi/plugin/context.rb +19 -7
  35. data/lib/samagotchi/process_group.rb +56 -9
  36. data/lib/samagotchi/recap_setup.rb +1 -1
  37. data/lib/samagotchi/self_report.rb +33 -3
  38. data/lib/samagotchi/session_commands.rb +47 -21
  39. data/lib/samagotchi/session_manager.rb +19 -1
  40. data/lib/samagotchi/session_metrics.rb +79 -11
  41. data/lib/samagotchi/sessions_command.rb +15 -5
  42. data/lib/samagotchi/terminal_ui/attached_loop.rb +85 -3
  43. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
  44. data/lib/samagotchi/terminal_ui/formatting.rb +45 -10
  45. data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
  46. data/lib/samagotchi/terminal_ui.rb +97 -12
  47. data/lib/samagotchi/tools/delegate_wait.rb +5 -4
  48. data/lib/samagotchi/tools/task_runtime.rb +11 -2
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +12 -11
  51. data/lib/samagotchi/web/public/app.js +30 -18
  52. data/lib/samagotchi/web/public/command_complete.js +3 -1
  53. data/lib/samagotchi/web/public/ctx.js +62 -22
  54. data/lib/samagotchi/web/public/format.js +7 -2
  55. data/lib/samagotchi/web/public/index.html +3 -1
  56. data/lib/samagotchi/web/public/llm_context_chip.js +2 -4
  57. data/lib/samagotchi/web/public/model_pick.js +23 -3
  58. data/lib/samagotchi/web/public/model_picker.js +5 -3
  59. data/lib/samagotchi/web/public/stage_model.js +18 -4
  60. data/lib/samagotchi/web/public/stage_view.js +4 -3
  61. data/lib/samagotchi/web/public/turn_events.js +19 -6
  62. data/lib/samagotchi/web/public/turn_model.js +16 -0
  63. data/lib/samagotchi/web/public/turn_view.js +19 -0
  64. data/lib/samagotchi/web/session_hub.rb +9 -1
  65. data/lib/samagotchi/web/session_summary.rb +19 -2
  66. data/lib/samagotchi/worker.rb +172 -21
  67. data/lib/samagotchi/worker_inbound.rb +6 -5
  68. 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
- # Caller holds the mutex.
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
- @screen.commit(prompt_line(event[:client_id], event[:line])) unless own?(event[:client_id]) || event[:anytime] || event[:card]
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] == "busy" || PromptHistory.shell_line?(event[:line])
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"); "" for
346
- # none. As ctx.js costText.
347
- def cost_text(cost)
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
- lines << "cost: #{cost_text(tokens[:cost_sum])} (this session only)" if tokens[:cost_sum].to_f.positive?
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[:window_tokens].to_i.positive? ? format(" (%.1f%%)", context[:used_tokens] * 100.0 / context[:window_tokens]) : ""
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
- note = ServedModel.differs?(asked, snapshot[:served_model]) ? " (asked for #{asked})" : ""
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
- segments = [model ? status_model_text(model, @values[:default_model], served: served, served_for: served_for) : "",
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]),