samagotchi 0.45.0 → 0.46.1

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -1
  3. data/docs/architecture.md +2 -1
  4. data/docs/cli.md +22 -7
  5. data/docs/configuration.md +105 -6
  6. data/docs/desktop.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/sessions.md +2 -2
  11. data/lib/samagotchi/bridge/turn_accumulator.rb +5 -0
  12. data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
  13. data/lib/samagotchi/bundles/github-pr/plugin.rb +26 -5
  14. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  15. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  16. data/lib/samagotchi/config.rb +61 -4
  17. data/lib/samagotchi/desktop/macos/Panel.swift +7 -3
  18. data/lib/samagotchi/engine.rb +41 -9
  19. data/lib/samagotchi/host_model.rb +123 -0
  20. data/lib/samagotchi/host_registry.rb +21 -10
  21. data/lib/samagotchi/kernel_loop.rb +2 -1
  22. data/lib/samagotchi/llm/chat_loop.rb +109 -7
  23. data/lib/samagotchi/llm/errors.rb +8 -2
  24. data/lib/samagotchi/llm/turn_settings.rb +5 -3
  25. data/lib/samagotchi/llm_context_strategy.rb +1 -0
  26. data/lib/samagotchi/model_catalog.rb +22 -16
  27. data/lib/samagotchi/model_price.rb +54 -0
  28. data/lib/samagotchi/model_profile.rb +7 -4
  29. data/lib/samagotchi/models_command.rb +12 -5
  30. data/lib/samagotchi/process_group.rb +56 -9
  31. data/lib/samagotchi/question_desk.rb +29 -4
  32. data/lib/samagotchi/recap_setup.rb +1 -1
  33. data/lib/samagotchi/self_report.rb +2 -1
  34. data/lib/samagotchi/served_model.rb +9 -0
  35. data/lib/samagotchi/session_commands.rb +27 -17
  36. data/lib/samagotchi/session_metrics.rb +40 -5
  37. data/lib/samagotchi/terminal_ui/attached_view.rb +2 -0
  38. data/lib/samagotchi/terminal_ui/formatting.rb +47 -15
  39. data/lib/samagotchi/terminal_ui/status_row.rb +12 -6
  40. data/lib/samagotchi/terminal_ui.rb +15 -0
  41. data/lib/samagotchi/tools/delegate_wait.rb +5 -4
  42. data/lib/samagotchi/tools/task_runtime.rb +11 -2
  43. data/lib/samagotchi/version.rb +1 -1
  44. data/lib/samagotchi/web/app.rb +43 -6
  45. data/lib/samagotchi/web/public/app.js +7 -3
  46. data/lib/samagotchi/web/public/ctx.js +20 -7
  47. data/lib/samagotchi/web/public/format.js +7 -2
  48. data/lib/samagotchi/web/public/index.html +3 -0
  49. data/lib/samagotchi/web/public/model_pick.js +36 -9
  50. data/lib/samagotchi/web/public/model_picker.js +11 -6
  51. data/lib/samagotchi/web/public/question_card.js +8 -0
  52. data/lib/samagotchi/web/public/stage_view.js +1 -0
  53. data/lib/samagotchi/web/public/turn_events.js +10 -5
  54. data/lib/samagotchi/web/public/turn_model.js +16 -0
  55. data/lib/samagotchi/web/public/turn_view.js +19 -0
  56. data/lib/samagotchi/worker.rb +20 -1
  57. metadata +4 -1
@@ -492,10 +492,11 @@ module Samagotchi
492
492
  end
493
493
  end
494
494
 
495
- # "; served: <name>" when the server serves another model than asked.
495
+ # "; served: <name>" when the server serves another model than asked
496
+ # and its host's served: doesn't expect it.
496
497
  def served_note
497
498
  served, asked = @engine.served_model
498
- ServedModel.differs?(asked, served) ? "; served: #{served}" : ""
499
+ ServedModel.differs?(asked, served) && !@engine.served_expected?(served) ? "; served: #{served}" : ""
499
500
  rescue StandardError
500
501
  ""
501
502
  end
@@ -567,37 +568,45 @@ module Samagotchi
567
568
 
568
569
  seen = Set.new
569
570
  lines = []
571
+ host_rows = HostModel.rows(results, registry.entries)
570
572
  # Sort hosts for deterministic output
571
573
  results.keys.sort.each do |hname|
572
574
  data = results[hname]
573
575
  host_label = "#{hname} (#{data[:host]}:#{data[:port]})"
576
+ # Declared ids (hosts.<name>.models) first, as "(config)", never cut
577
+ # by MODELS_PER_HOST; a host that failed keeps only those, as
578
+ # "(config, host down)" under its unreachable line.
579
+ rows = host_rows.fetch(hname, [])
574
580
  if data[:error]
575
581
  lines << "#{host_label} — unreachable: #{data[:error]}"
576
- next
577
- end
578
- models = Array(data[:models])
579
- if models.empty?
582
+ next if rows.empty?
583
+ elsif rows.empty?
580
584
  lines << "#{host_label} — no models discovered"
581
585
  next
582
586
  end
587
+ declared = []
583
588
  shown = []
584
- batch_variants = batch_variant_ids(models, needle)
585
- models.each do |entry|
586
- identifier = entry.id.to_s.empty? ? "unknown" : entry.id
589
+ batch_variants = batch_variant_ids(rows, needle)
590
+ rows.each do |row|
591
+ identifier = row.id.to_s.empty? ? "unknown" : row.id
587
592
  seen << identifier.to_s.downcase
588
593
  # also track host-qualified seen for orphan logic
589
594
  seen << "#{hname}:#{identifier}".downcase
590
- next if batch_variants.include?(identifier)
595
+ next if !row.configured && batch_variants.include?(identifier)
596
+ next unless needle.empty? || identifier.to_s.downcase.include?(needle)
591
597
 
592
- shown << [entry, identifier] if needle.empty? || identifier.to_s.downcase.include?(needle)
598
+ (row.configured ? declared : shown) << [row, identifier]
593
599
  end
594
- next if shown.empty?
600
+ next if declared.empty? && shown.empty?
595
601
 
596
- lines << "#{host_label}:"
602
+ lines << "#{host_label}:" unless data[:error]
597
603
  hidden = needle.empty? ? [shown.size - MODELS_PER_HOST, 0].max : 0
598
- shown.first(shown.size - hidden).each do |entry, identifier|
599
- raw_status = entry.raw["status"] || entry.raw[:status]
604
+ (declared + shown.first(shown.size - hidden)).each do |row, identifier|
605
+ raw = row.info&.raw || {}
606
+ raw_status = raw["status"] || raw[:status]
600
607
  status = raw_status.is_a?(Hash) ? (raw_status["value"] || raw_status[:value] || raw_status["status"] || raw_status[:status]) : raw_status
608
+ status = ["config", status].reject { |s| s.to_s.empty? }.join(", ") if row.configured
609
+ status = "#{status}, host down" if row.unavailable
601
610
  base = status.to_s.empty? ? " #{identifier}" : " #{identifier} (#{status})"
602
611
  alias_list = (by_model[identifier.to_s.downcase] || []) + (by_model["#{hname}:#{identifier}".downcase] || [])
603
612
  alias_list.uniq!
@@ -631,9 +640,10 @@ module Samagotchi
631
640
  def batch_variant_ids(models, needle)
632
641
  return Set.new if needle.include?("batch")
633
642
 
643
+ # Any case: a declared id keeps its own spelling (HostModel.rows).
634
644
  ids = models.map { |entry| entry.id.to_s }
635
- plain = ids.to_set
636
- ids.select { |id| id.end_with?(":batch") && plain.include?(id.delete_suffix(":batch")) }.to_set
645
+ plain = ids.to_set(&:downcase)
646
+ ids.select { |id| id.downcase.end_with?(":batch") && plain.include?(id.downcase.delete_suffix(":batch")) }.to_set
637
647
  end
638
648
  end
639
649
  end
@@ -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,
@@ -166,6 +177,7 @@ module Samagotchi
166
177
  SavedSummary.new(ctx_pct: saved_pct(data["context"], budget_tokens), prompt_sum: count.call("prompt_sum"),
167
178
  completion_sum: count.call("completion_sum"), cached_sum: count.call("cached_sum"),
168
179
  reasoning_sum: count.call("reasoning_sum"), cost_sum: count.call("cost_sum"),
180
+ cost_estimate_sum: count.call("cost_estimate_sum"),
169
181
  memory_index: data["memory_index"].is_a?(Hash) ? data["memory_index"] : nil)
170
182
  rescue JSON::ParserError, SystemCallError, TypeError
171
183
  nil
@@ -322,6 +334,7 @@ module Samagotchi
322
334
  @turn.gen_started_at = monotonic_time
323
335
  @turn.generations += 1
324
336
  reset_generation_tokens
337
+ @turn.gen_price = generation_price(event[:price])
325
338
  @turn.gen_open = true
326
339
  end
327
340
  end
@@ -457,6 +470,7 @@ module Samagotchi
457
470
  reprefill_sum: add.call(:reprefill_sum),
458
471
  reasoning_sum: add.call(:reasoning_sum),
459
472
  cost_sum: add.call(:cost_sum),
473
+ cost_estimate_sum: add.call(:cost_estimate_sum),
460
474
  decode_ms_sum: decode_ms.round,
461
475
  decode_tokens_sum: decode_tokens,
462
476
  avg_decode_tps: decode_ms.positive? ? (decode_tokens * 1000.0 / decode_ms).round(1) : nil,
@@ -508,6 +522,7 @@ module Samagotchi
508
522
  gen_reasoning_max: 0,
509
523
  generations: 0,
510
524
  prompt_last: nil,
525
+ prompt_max: nil,
511
526
  prompt_sum: 0,
512
527
  completion_last: 0,
513
528
  completion_sum: 0,
@@ -517,6 +532,7 @@ module Samagotchi
517
532
  reprefill_sum: 0,
518
533
  reasoning_sum: 0,
519
534
  cost_sum: nil,
535
+ cost_estimate_sum: 0,
520
536
  decode_ms_sum: 0,
521
537
  decode_tokens_sum: 0,
522
538
  retries: 0,
@@ -627,6 +643,7 @@ module Samagotchi
627
643
  if turn.gen_had_server
628
644
  completion = turn.gen_completion_max
629
645
  turn.prompt_last = turn.gen_prompt_max
646
+ turn.prompt_max = [turn.prompt_max.to_i, turn.gen_prompt_max].max
630
647
  turn.prompt_sum += turn.gen_prompt_max
631
648
  turn.token_sources |= ["server"]
632
649
  speed = close_server_report(turn, completion)
@@ -656,6 +673,12 @@ module Samagotchi
656
673
  turn.cache_write_sum += turn.gen_cache_write_max
657
674
  turn.reasoning_sum += turn.gen_reasoning_max
658
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
659
682
  turn.last_prefill_tps = turn.gen_prefill_tps.round(1) if turn.gen_prefill_tps
660
683
  speed = generation_speed(turn, completion)
661
684
  return unless speed
@@ -685,7 +708,9 @@ module Samagotchi
685
708
  [tps, decode_ms, source]
686
709
  end
687
710
 
688
- # 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.
689
714
  def reset_generation_tokens
690
715
  @turn.gen_completion_max = 0
691
716
  @turn.gen_prompt_max = 0
@@ -708,6 +733,7 @@ module Samagotchi
708
733
  model: turn.model,
709
734
  generations: turn.generations,
710
735
  prompt_tokens: turn.prompt_last,
736
+ prompt_tokens_max: turn.prompt_max,
711
737
  prompt_tokens_sum: turn.prompt_sum,
712
738
  completion_tokens: turn.completion_sum,
713
739
  context_used_tokens: turn.prompt_last && (turn.prompt_last + turn.completion_last),
@@ -738,6 +764,7 @@ module Samagotchi
738
764
  fields[:cache_write_tokens_sum] = turn.cache_write_sum if turn.cache_write_sum.positive?
739
765
  fields[:reprefill_tokens_sum] = turn.reprefill_sum if turn.reprefill_sum.positive?
740
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?
741
768
  fields
742
769
  end
743
770
 
@@ -862,6 +889,13 @@ module Samagotchi
862
889
  event_content.is_a?(String) ? event_content : ""
863
890
  end
864
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
+
865
899
  def monotonic_time
866
900
  @clock.call
867
901
  end
@@ -981,7 +1015,7 @@ module Samagotchi
981
1015
  @totals = { cancellations: 0, prompt_sum: 0, completion_sum: 0, token_sources: [], iterations: 0,
982
1016
  gen_ms: 0, retries: 0, cuts: 0, capped: 0, by_tool: {}, tool_errors: 0, cached_sum: 0, cache_write_sum: 0,
983
1017
  reprefill_sum: 0, reasoning_sum: 0,
984
- 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,
985
1019
  last_prefill_tps: nil, tps_source: nil }
986
1020
  end
987
1021
 
@@ -1000,6 +1034,7 @@ module Samagotchi
1000
1034
  totals[:reprefill_sum] += number(record[:reprefill_tokens_sum])
1001
1035
  totals[:reasoning_sum] += number(record[:reasoning_tokens])
1002
1036
  totals[:cost_sum] += number(record[:cost])
1037
+ totals[:cost_estimate_sum] += number(record[:cost_estimate])
1003
1038
  totals[:decode_ms_sum] += number(record[:decode_ms])
1004
1039
  totals[:decode_tokens_sum] += number(record[:decode_tokens])
1005
1040
  # The newest speeds a record has (an older record has none).
@@ -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
@@ -96,14 +96,29 @@ module Samagotchi
96
96
  paint(TurnNote.empty_answer_line(retries), 90)
97
97
  end
98
98
 
99
- # "retrying (1/3 in 0.5s): Errno::ECONNREFUSED": the retry of all the
100
- # retries there will be (as the web counts), the wait before it, and
101
- # what failed (generation_retrying).
99
+ # "↻ retrying (503) in 4.0s, 2/5": the live status while the provider
100
+ # is asked again (generation_retrying), worded as the web words it
101
+ # (turn_events.js retryStatusLine; spec/shared/labels_matrix.json):
102
+ # why (the HTTP status, the error's short class name, or "stream
103
+ # dropped" for a dropped stream's step asked again), the wait before
104
+ # it, and the retry of all the retries there will be.
102
105
  def format_generation_retry_line(event)
103
- return "retrying (attempt #{event[:attempt]})" unless event[:max_retries]
106
+ why = event[:restarted] ? "stream dropped" : retry_cause(event)
107
+ delay = Float(event[:next_delay], exception: false)
108
+ text = +"\u21bb retrying"
109
+ text << " (#{why})" unless why.empty?
110
+ text << " in #{format("%.1f", (delay * 10).round / 10.0)}s" if delay&.finite?
111
+ text << ", #{event[:attempt]}#{"/#{event[:max_retries]}" if event[:max_retries]}" if event[:attempt]
112
+ text
113
+ end
114
+
115
+ # What failed, for a retry line: the HTTP status, else the error's
116
+ # class name without its namespace (Errno::ECONNREFUSED: ECONNREFUSED).
117
+ def retry_cause(event)
118
+ status = event[:status]
119
+ return status.to_s unless status.nil? || status.to_s.empty? || status.to_s == "0"
104
120
 
105
- text = "retrying (#{event[:attempt]}/#{event[:max_retries]} in #{format("%.1f", event[:next_delay].to_f)}s)"
106
- event[:error_class].to_s.empty? ? text : "#{text}: #{event[:error_class]}"
121
+ event[:error_class].to_s.split("::").last.to_s
107
122
  end
108
123
 
109
124
  # " → image 1280×800" after a tool line whose tool read an image.
@@ -342,13 +357,28 @@ module Samagotchi
342
357
  "(#{scopes.map { |scope, tokens| "#{scope} #{count.call(tokens)}" }.join(", ")})"
343
358
  end
344
359
 
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)
360
+ # "$0.42"; a cost under a cent keeps four decimals ("$0.0012"); "~$0.12"
361
+ # for an estimate (hosts.<name>.models prices); "" for none. As ctx.js
362
+ # costText.
363
+ def cost_text(cost, estimate: false)
348
364
  value = cost.to_f
349
365
  return "" unless value.positive?
350
366
 
351
- value >= 0.01 ? format("$%.2f", value) : format("$%.4f", value)
367
+ "#{"~" if estimate}#{value >= 0.01 ? format("$%.2f", value) : format("$%.4f", value)}"
368
+ end
369
+
370
+ # /stats' cost: the reported part and the estimated one, each when present.
371
+ # +price_host+ names the host whose hosts.<name>.models prices the
372
+ # estimate came from (Engine#stats_snapshot; nil from an older worker).
373
+ def stats_cost_text(tokens, price_host: nil)
374
+ reported = cost_text(tokens[:cost_sum])
375
+ estimated = cost_text(tokens[:cost_estimate_sum], estimate: true)
376
+ return nil if reported.empty? && estimated.empty?
377
+ return "#{reported} (this session only)" if estimated.empty?
378
+
379
+ source = price_host.to_s.empty? ? "configured prices" : "hosts.#{price_host}.models prices"
380
+ estimate = "#{estimated} from #{source}"
381
+ "#{reported.empty? ? estimate : "#{reported} reported + #{estimate}"} (this session only)"
352
382
  end
353
383
 
354
384
  private
@@ -411,9 +441,9 @@ module Samagotchi
411
441
 
412
442
  # +served+ / +served_for+: the model the server said it served for the
413
443
  # 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)
444
+ # model, unless +expected+ (the host's served: names it).
445
+ def status_model_text(model, default_model, served: nil, served_for: nil, expected: false)
446
+ if !expected && ServedModel.differs?(served_for, served)
417
447
  asked = model.to_s.length > STATUS_ASKED_MAX ? "#{model.to_s[0, STATUS_ASKED_MAX - 1]}…" : model
418
448
  return "model=#{served} (served; asked #{asked})"
419
449
  end
@@ -477,7 +507,8 @@ module Samagotchi
477
507
  "#{token_breakdown_text(tokens)}"
478
508
  speed = stats_speed_text(tokens)
479
509
  lines << "speed: #{speed}" if speed
480
- lines << "cost: #{cost_text(tokens[:cost_sum])} (this session only)" if tokens[:cost_sum].to_f.positive?
510
+ cost = stats_cost_text(tokens, price_host: snapshot[:price_host])
511
+ lines << "cost: #{cost}" if cost
481
512
  lines << "gen latency (ms): #{snapshot[:gen_latency_ms]}"
482
513
  lines << "cancellations: #{snapshot[:cancellations]}"
483
514
  lines << "retries: #{snapshot[:retries]}"
@@ -500,7 +531,8 @@ module Samagotchi
500
531
  lines << "llm context: #{llm_context}" if llm_context
501
532
  if snapshot[:served_model]
502
533
  asked = snapshot[:served_model_for]
503
- note = ServedModel.differs?(asked, snapshot[:served_model]) ? " (asked for #{asked})" : ""
534
+ differs = !snapshot[:served_expected_by] && ServedModel.differs?(asked, snapshot[:served_model])
535
+ note = differs ? " (asked for #{asked})" : ""
504
536
  lines << "served model: #{snapshot[:served_model]}#{note}"
505
537
  end
506
538
  lines.join("\n")
@@ -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]),
@@ -1322,6 +1322,8 @@ module Samagotchi
1322
1322
 
1323
1323
  # The activity row goes while the question waits.
1324
1324
  @view.finish_thinking_spinner
1325
+ return dismiss_unaskable_question(prompt) if @non_interactive
1326
+
1325
1327
  # An edit's diff goes above the slot, into the scrollback.
1326
1328
  preview = prompt.preview_lines(paint: method(:paint))
1327
1329
  @surface.commit(preview.join("\n")) unless preview.empty?
@@ -1345,6 +1347,19 @@ module Samagotchi
1345
1347
  @surface.clear_slot(:notes)
1346
1348
  end
1347
1349
 
1350
+ # --non-interactive has no one to ask (as for an approval or a hook's
1351
+ # question there): the question is dismissed at once, as an empty answer
1352
+ # would be, and the model finishes its reply. Never a read of stdin: a
1353
+ # pipe its writer keeps open would block it forever. Its one line goes to
1354
+ # stderr (#banner): stdout is the answer's alone. The model is told no
1355
+ # one could answer (QuestionDesk::UNANSWERABLE_NOTE), not that the user
1356
+ # dismissed it.
1357
+ def dismiss_unaskable_question(prompt)
1358
+ @engine.cancel_question(QuestionDesk::UNANSWERABLE_REASON) rescue nil
1359
+ banner(prompt.summary("(dismissed: no one to answer in a non-interactive run)", paint: method(:paint)))
1360
+ false
1361
+ end
1362
+
1348
1363
  # Loop until a valid selection or a cancel. The block reads one answer:
1349
1364
  # a line, nil (Ctrl-D, or Ctrl-C with no turn), or :canceled (the turn
1350
1365
  # was cancelled).
@@ -84,10 +84,11 @@ module Samagotchi
84
84
  outcomes << outcome.line
85
85
  return with_outcomes(canceled_result(child_id, reports: reports), outcomes) if outcome.stopped
86
86
 
87
- # Wait on with the child as it is now: a turn that ends with no
88
- # reply after the relay still ends the wait, and the relayed
89
- # question isn't reported again.
90
- baseline = ReplyWait.baseline_of(Session.load(child_id, state_dir: sd), question_id: wait.question[:id])
87
+ # Wait on from the child as it was before the relay (read when it
88
+ # asked), the relayed question no longer reported: a turn that
89
+ # ends with no reply during the relay's POST or after it still
90
+ # ends the wait.
91
+ baseline = ReplyWait.baseline_of(wait.session, question_id: wait.question[:id])
91
92
  end
92
93
  rescue ArgumentError => e
93
94
  "Error: #{e.message}"
@@ -66,6 +66,8 @@ module Samagotchi
66
66
  err: output_io
67
67
  )
68
68
  Process.detach(pid)
69
+ # Read now, compared before each signal: a pid reused later is not this task.
70
+ pid_started = ProcessGroup.start_time(pid)
69
71
 
70
72
  now = timestamp
71
73
  record = {
@@ -76,6 +78,7 @@ module Samagotchi
76
78
  "workspace_root" => Dir.pwd,
77
79
  "status" => "running",
78
80
  "pid" => pid,
81
+ "pid_started" => pid_started,
79
82
  "created_at" => now,
80
83
  "started_at" => now,
81
84
  "finished_at" => nil,
@@ -190,7 +193,7 @@ module Samagotchi
190
193
 
191
194
  pid = refreshed["pid"]
192
195
  # Checked again just before the signal: never a group chi didn't start.
193
- return [mark_not_chis(refreshed), nil] unless ProcessGroup.leader?(pid)
196
+ return [mark_not_chis(refreshed), nil] unless chis_group?(refreshed, started: refreshed["pid_started"])
194
197
 
195
198
  refreshed["stop_requested_by"] = by.to_s
196
199
  write_record(refreshed)
@@ -210,13 +213,19 @@ module Samagotchi
210
213
  return record unless record["status"] == "running"
211
214
 
212
215
  pid = record["pid"]
213
- return record if ProcessGroup.leader?(pid)
216
+ return record if chis_group?(record)
214
217
  # Gone: its exit code says how it ended.
215
218
  return mark_finished_without_exit_code(record) if ProcessGroup.signalable?(pid) && !ProcessGroup.alive?(pid)
216
219
 
217
220
  mark_not_chis(record)
218
221
  end
219
222
 
223
+ # Whether the record's pid still leads its own group. With +started+
224
+ # (the start time recorded at task_create), also the same process: a
225
+ # ps per call, so only task_stop asks it, just before the signal;
226
+ # a refresh (each task_wait poll, task_list) checks the group only.
227
+ def chis_group?(record, started: nil) = ProcessGroup.leader?(record["pid"], started: started)
228
+
220
229
  # A running record whose pid isn't a group chi started (NOT_CHIS_PROCESS)
221
230
  # ends as failed, unless its exit code or a stop on disk says more.
222
231
  def mark_not_chis(record)
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Samagotchi
4
- VERSION = "0.45.0"
4
+ VERSION = "0.46.1"
5
5
  # Sent as the User-Agent of every HTTP request chi makes.
6
6
  USER_AGENT = "chi/#{VERSION}"
7
7
  end
@@ -24,6 +24,7 @@ require_relative "../llm_context_override"
24
24
  require_relative "../model_catalog"
25
25
  require_relative "../model_profile"
26
26
  require_relative "../sampling_settings"
27
+ require_relative "../served_model"
27
28
  require_relative "../project_scope"
28
29
  require_relative "../prompt_history"
29
30
  require_relative "../version"
@@ -443,7 +444,10 @@ module Samagotchi
443
444
  warnings.concat(listing.warnings)
444
445
  # the web's own spelling, kept as it was: bare on the default host
445
446
  listing.rows.each do |row|
446
- models << { name: row.host == default_host ? row.id : "#{row.host}:#{row.id}", host: row.host, id: row.id }
447
+ model = { name: row.host == default_host ? row.id : "#{row.host}:#{row.id}", host: row.host, id: row.id }
448
+ model[:configured] = true if row.configured
449
+ model[:unavailable] = true if row.unavailable
450
+ models << model
447
451
  end
448
452
  end
449
453
  rescue StandardError => e
@@ -485,6 +489,33 @@ module Samagotchi
485
489
  nil
486
490
  end
487
491
 
492
+ # The served model a session's last generation reported (analytics.json,
493
+ # SessionMetrics#persist), for a session no worker runs: shown only
494
+ # while the name it answered is still the session's model, as
495
+ # Engine#served_model does, with the host whose served: expects it.
496
+ # {} when none applies.
497
+ def saved_served_model(session, persisted)
498
+ served = persisted["served_model"]
499
+ model = session.model_name.to_s
500
+ return {} unless served.is_a?(String) && !served.empty? && !model.empty?
501
+
502
+ registry = host_registry
503
+ asked = registry.bare_name(model)
504
+ return {} unless persisted["served_model_for"] == asked
505
+
506
+ { served_model: served, served_model_for: asked, served_expected_by: saved_served_expected_by(registry, model, served) }
507
+ rescue StandardError
508
+ {}
509
+ end
510
+
511
+ # See ServedModel.expected_by; nil when the model doesn't resolve (the
512
+ # mismatch still shows).
513
+ def saved_served_expected_by(registry, model, served)
514
+ ServedModel.expected_by(registry.resolve(model), served)
515
+ rescue StandardError
516
+ nil
517
+ end
518
+
488
519
  # A session's LLM context strategy, apply rule and budget, each with
489
520
  # where it came from (LLMContextStrategy::Explained#summary), as its
490
521
  # worker would resolve them; nil when they can't be.
@@ -655,17 +686,22 @@ module Samagotchi
655
686
  # A /model in the worker changes it before the file catches up.
656
687
  session.model_name = snapshot["model_name"] if snapshot && !snapshot["model_name"].to_s.empty?
657
688
  session_json = session_to_json(session, status: displayed_status(session, snapshot, owner: owner), owner: owner)
658
- # What the worker's server said it served for that model (after a turn).
689
+ # What the worker's server said it served for that model (after a turn);
690
+ # without a worker, what the session's analytics.json last saved.
691
+ persisted = snapshot ? nil : read_analytics(id)
659
692
  if snapshot
660
- session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"])
693
+ session_json = session_json.merge(served_model: snapshot["served_model"], served_model_for: snapshot["served_model_for"],
694
+ served_expected_by: snapshot["served_expected_by"])
661
695
  # The model notes its prompt carries now (a /model there changes them).
662
696
  session_json = session_json.merge(prompt_notes: snapshot["prompt_notes"]) if snapshot["prompt_notes"].is_a?(Array)
697
+ else
698
+ session_json = session_json.merge(saved_served_model(session, persisted))
663
699
  end
664
700
  # The LLM context strategy the next turn runs under (the info bar's
665
701
  # chip): the worker's, else worked out here from the session file.
666
702
  session_json = session_json.merge(llm_context: (snapshot && snapshot["llm_context"]) || llm_context_for(session))
667
703
  raw_messages = turn_snapshot ? turn_snapshot["messages"] : session.messages
668
- timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"])
704
+ timing = timing_payload(id, live_metrics: snapshot && snapshot["metrics"], persisted: persisted)
669
705
  json_response(200, {
670
706
  session: session_json,
671
707
  history: read_history(id),
@@ -1499,8 +1535,9 @@ module Samagotchi
1499
1535
 
1500
1536
  # The whole timing: analytics.json's records merged with a live
1501
1537
  # worker's (by id), and turn_count, the finished turns (#turn_count).
1502
- def timing_payload(session_id, live_metrics: nil)
1503
- persisted = read_analytics(session_id)
1538
+ # +persisted+: the session's analytics.json when the caller read it.
1539
+ def timing_payload(session_id, live_metrics: nil, persisted: nil)
1540
+ persisted ||= read_analytics(session_id)
1504
1541
  live = live_metrics.is_a?(Hash)
1505
1542
  source = if live
1506
1543
  persisted.merge(live_metrics).merge(
@@ -1156,7 +1156,7 @@ function renderSessionView(data) {
1156
1156
  setCapabilities(data.capabilities);
1157
1157
  current.status = s.status;
1158
1158
  current.model = s.model_name;
1159
- current.served = s.served_model ? [s.served_model, s.served_model_for] : null;
1159
+ current.served = s.served_model ? [s.served_model, s.served_model_for, s.served_expected_by || null] : null;
1160
1160
  current.dir = s.working_directory;
1161
1161
  current.owner = s.owner || null;
1162
1162
  current.timing = normalizeTiming(data.timing);
@@ -2007,7 +2007,7 @@ const streamHandlers = {
2007
2007
  },
2008
2008
  generation_completed: (data) => {
2009
2009
  liveTurn.timing.setNote("");
2010
- if (data?.served_model) current.served = [data.served_model, data.requested_model];
2010
+ if (data?.served_model) current.served = [data.served_model, data.requested_model, data.served_expected_by || null];
2011
2011
  // The generation's speed and the session's token totals, from the server.
2012
2012
  current.timing.tokens = generationTokens(current.timing.tokens, data);
2013
2013
  if (data?.served_model || data?.tokens) updateInfoBar();
@@ -2212,7 +2212,11 @@ const streamHandlers = {
2212
2212
  llm_context_edited: (data) => { turnDom.notice(llmContextNotice(data)); },
2213
2213
  // The provider is asked again after an error, or the turn waits for
2214
2214
  // plugins' setup: a note on the live timing line until it streams.
2215
- generation_retrying: (data) => liveTurn.timing.setNote(retryStatusLine(data)),
2215
+ generation_retrying: (data) => {
2216
+ // A dropped stream's step asked again: what it streamed is void.
2217
+ if (data?.restarted) turnDom.generationRestarted(data);
2218
+ liveTurn.timing.setNote(retryStatusLine(data));
2219
+ },
2216
2220
  plugin_init_wait: (data) => liveTurn.timing.setNote(initWaitLine(data)),
2217
2221
  plugin_init_started: (data) => onInitEvent("plugin_init_started", data),
2218
2222
  plugin_init_finished: (data) => onInitEvent("plugin_init_finished", data),