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
@@ -616,21 +616,42 @@ class LineLinker
616
616
  end
617
617
 
618
618
  # The PR file +path+ names: the path (or a rename's old one) exactly,
619
- # after a leading `./`; else the longest PR path +path+ ends with on a
620
- # `/` boundary (an absolute path into a worktree); else the one PR path
621
- # that ends with +path+ (a bare `foo.rb`). nil: none, or several.
619
+ # after a leading `./`; else, for an absolute path (into a worktree) or
620
+ # one up from the cwd (`../lib/foo.rb` from a subdir), the longest PR
621
+ # path it ends with on a `/` boundary (see #worktree_suffix); else the
622
+ # one PR path that ends with +path+ (a bare `foo.rb`). nil: none, or
623
+ # several. A relative path that only ends with a PR path
624
+ # (`vendor/lib/foo.rb`) is another file.
622
625
  def resolve(files, path)
623
626
  path = path.sub(%r{\A(?:\./)+}, "")
624
627
  exact = files.find { |file| file.path == path } || files.find { |file| file.previous_path == path }
625
628
  return exact if exact
626
629
 
627
- longer = files.select { |file| path.end_with?("/#{file.path}") }.max_by { |file| file.path.length }
628
- return longer if longer
630
+ if path.start_with?("/", "~/", "../")
631
+ longer = files.select { |file| path.end_with?("/#{file.path}") }.sort_by { |file| -file.path.length }
632
+ return path.start_with?("../") ? longer.first : worktree_suffix(longer, path)
633
+ end
629
634
 
630
635
  shorter = files.select { |file| file.path.end_with?("/#{path}") }
631
636
  shorter.one? ? shorter.first : nil
632
637
  end
633
638
 
639
+ # The first of +files+ (longest path first) that the absolute +path+
640
+ # names inside a worktree: the part before the PR path is a directory
641
+ # with a `.git` (/abs/repo/vendor/lib/foo.rb is a vendored copy, not the
642
+ # PR's lib/foo.rb), or no directory here at all (a path from elsewhere;
643
+ # nothing to tell by). nil: each part before is a directory, none a
644
+ # worktree's root.
645
+ def worktree_suffix(files, path)
646
+ full = File.expand_path(path)
647
+ files.find do |file|
648
+ root = full.delete_suffix("/#{file.path}")
649
+ !File.directory?(root) || File.exist?(File.join(root, ".git"))
650
+ end
651
+ rescue ArgumentError
652
+ files.first
653
+ end
654
+
634
655
  # The PR's Files changed anchor when the lines are inside one hunk (the
635
656
  # new side; a removed file's old side), else the file at the PR's head
636
657
  # (a removed one: at its base), which has every line.
@@ -57,7 +57,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
57
57
  **Excluded maps** (YAML-only, not part of the flat registry; skipped by scalar loader):
58
58
 
59
59
  - `model_aliases:` map of alias → model id (`ModelRef.parse`, through `HostRegistry#resolve`). Keys lowercased on write (`ConfigFile.write_model_alias!`). Values may be bare `model` or qualified `host:model` (hybrid; only `:` names a host, `org/model` is an id). Aliases apply once: never point an alias at another alias.
60
- - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
60
+ - `hosts:` map of `name → {host, port | url, transport, api, api_key_env, profile, first_token_timeout, vision, sampling, thinking, enabled, models}` (`ConfigFile.hosts_config`, `host_registry.rb` `HostEntry`). Names lowercased; `url:` (http/https, optional path) replaces host/port, never both; `api_key_env:` names the env var holding the API key (never write a key into config.yml); `transport` overrides `server.transport`; `first_token_timeout` (seconds, `0` = off; a negative or non-number warns and is ignored) overrides `server.first_token_timeout` for that host; `models:` (a map of model ids, or a plain list) declares ids the host serves whatever its `/v1/models` lists: no "doesn't list" warning, and a bare id routes there as if listed (`HostModel`); an entry may hold `price: {input, output, cache_read?, cache_write?}` in USD per 1M tokens (`ModelPrice`; an unset cache rate is the input rate), which estimates a cost the provider doesn't report or reports as 0 (`cost_estimate`, shown as `~$`), and `served: [ids] | any`, the models a gateway may answer that id with (no served-model warning for them); model settings stay in the root `models:`; workers inherit via `SAMAGOTCHI_HOSTS_JSON` (`hosts_json_for_env`, `session_manager.rb`).
61
61
  - `models:` map of model id or alias → `{profile, vision, sampling, thinking}` (`ConfigFile.model_settings`). `thinking:` (here, on a host, or `thinking.level` for every model) is `off|low|medium|high|default` (`Thinking`; unquoted `off` works, `on` is not a level; `default` = send nothing). Precedence: `--thinking`/`SAMAGOTCHI_THINKING_LEVEL` > `models:` > `hosts.<name>.thinking` > `thinking.level` in config.yml > `default`. A `sampling:` key wins over the fields a level sends (`null` drops one); `docs/configuration.md` "Thinking". `sampling:` (here or on a host) is a map of request fields passed to the provider as written (`temperature`, `top_p`, `presence_penalty`, `repeat_penalty`, …; a model's fields win over its host's per field; `null` = don't send; chi's own fields like `max_tokens`/`stream` are refused with a warning; `docs/configuration.md` "Sampling"). Keys match case-insensitively. `profile` (here or on a host) is `qwen36|gemma4`: the raw prompt format for native hosts. Precedence: `--profile`/`SAMAGOTCHI_MODEL_PROFILE` > `models:` > `hosts.<name>.profile` > the llama.cpp server's chat template > the name (`qwen`/`gemma`) > `qwen36` (`ModelProfile.resolve`). Set one when a model's name hides its family (e.g. a Qwen fine-tune under another name on mlx, which has no template to read).
62
62
  - `hooks:` map of `hooks_dir` + per-event lists `{path, on_error}` (`Hooks::Loader.load`). `hooks_dir` may start with `~`.
63
63
  - `guardrails:` tool-call rules (`docs/guardrails.md`; read in `Engine#guardrail_rules`, parsed by `Guardrails::Rules.parse`): `enabled` (bool, default true; `false` drops rules and hooks' asks, a deny still applies; config.yml only, like every `guardrails` setting: no env variable), `parent_approvals` (`off`, the default: a parent agent's `chi answer` can only deny an approval; `once`: it may give "Allow once" too; a convention for an honest parent, not a security boundary, and the user's choice: set it only when the user asks), `rules:` (list), `disable:` (a rule id or a list of them, `id` or `bundle:id`, switching off a bundle's or config rule without editing it), `small_models` (`auto`, the default: small = 32B or less read from the name; a glob or list of globs; `[]` for none). A rule takes only `id`, `tool`, `command`, `path`, `git`, `models`, `verdict`, `reason`, `scopes`: `id` required; at least one of `tool` (a name, `shell` = execute + task_create, a `File.fnmatch` glob like `"mcp_*"`, or a list), `command` (a Ruby regex on the shell command), `path` (a glob, or `outside_repo`: outside the session's repo, tmp folders and memory files excepted), `git` (only `outside_repo`: a shell call runs commit/add/reset/… in another checkout); `models` (optional: `small`, a model-name glob, or a list; without it the rule is for every model); `verdict` `ask|deny`; `scopes` (for `ask`) a subset of `once, session, repo, rule`. **Any parse error (an unknown key, a bad regex, no verdict) makes chi deny every tool call** until fixed, so validate with `YAML.safe_load` and keep the list shape. Rules are read again on the next tool call after config.yml or a bundle's rules change: no restart needed. Installed bundles' rule files (`chi bundle install guardrails`) add to them; `/guardrails` lists what loaded.
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.45.0
3
+ version: 0.46.1
4
4
  scope: system
5
5
  description: Default system memories — identity, self map, config modification protocol, memory guide and the delegated-session rules
6
6
  files:
7
7
  identity.md: sha256:29c2f7df9f6c5acbc5ec17000a4490741e55e426af15ca0d81c8706edd9bd027
8
8
  self_map.md: sha256:dfc19266259a9af1e0d769b7e0f67996b740b0c9921fdf3dadad973f6e5c144a
9
- config_modification_protocol.md: sha256:2d4135ce4fafcd391d1782dc3a54f7cbf10cd35ebe9f004b2d6cb256517a8451
9
+ config_modification_protocol.md: sha256:337ad1b4cc9308dc5abb47b13f51a76df4691ba80fe41648f391687b5b929b65
10
10
  memory_guide.md: sha256:15cd3b24eda853cc458371c6c75809a2d4b5352311d075aaa4384926b5221c27
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -10,6 +10,7 @@ require_relative "log"
10
10
  require_relative "model_ref"
11
11
  require_relative "paths"
12
12
  require_relative "llm_context_strategy"
13
+ require_relative "host_model"
13
14
 
14
15
  module Samagotchi
15
16
  # Parses the thinking: levels of host and model entries (it needs Config).
@@ -263,10 +264,12 @@ module Samagotchi
263
264
  # (ConfigFile.hosts_config, ConfigFile.model_settings).
264
265
  MAP_ENTRY_KEYS = {
265
266
  "hosts" => %w[host port url transport api api_key_env profile first_token_timeout vision sampling thinking enabled
266
- remote window_tokens llm_context_strategy llm_context_apply llm_context_budget_tokens].freeze,
267
+ remote window_tokens llm_context_strategy llm_context_apply llm_context_budget_tokens models].freeze,
267
268
  "models" => %w[profile vision sampling thinking window_tokens llm_context_strategy llm_context_apply
268
269
  llm_context_budget_tokens].freeze
269
270
  }.freeze
271
+ # The keys a hosts.<name>.models entry may hold (HostModel.parse_map).
272
+ HOST_MODEL_KEYS = %w[price served].freeze
270
273
  # Section keys beyond the registry's: guardrails' YAML rules (Engine#guardrail_rules).
271
274
  SECTION_EXTRA_KEYS = { "guardrails" => %w[rules disable].freeze }.freeze
272
275
 
@@ -615,7 +618,33 @@ module Samagotchi
615
618
 
616
619
  (entry.keys.map(&:to_s) - allowed).map do |k|
617
620
  unknown_key_message("#{map}.#{name}.#{k}", k, allowed, prefix: "#{map}.#{name}.")
618
- end
621
+ end + (map == "hosts" ? host_model_problems(name, entry["models"]) : [])
622
+ end
623
+ end
624
+
625
+ # hosts.<name>.models' entries, one level further down.
626
+ def host_model_problems(host, models)
627
+ return [] unless models.is_a?(Hash)
628
+
629
+ models.flat_map do |id, model|
630
+ next [] unless model.is_a?(Hash)
631
+
632
+ prefix = "hosts.#{host}.models.#{id}."
633
+ (model.keys.map(&:to_s) - HOST_MODEL_KEYS).map do |k|
634
+ unknown_key_message("#{prefix}#{k}", k, HOST_MODEL_KEYS, prefix: prefix)
635
+ end + price_key_problems("#{prefix}price.", model["price"])
636
+ end
637
+ end
638
+
639
+ # A price key's old name: the warning suggests the new one.
640
+ RENAMED_PRICE_KEYS = { "cached" => "cache_read" }.freeze
641
+
642
+ def price_key_problems(prefix, price)
643
+ return [] unless price.is_a?(Hash)
644
+
645
+ (price.keys.map(&:to_s) - ModelPrice::KEYS).map do |k|
646
+ # A renamed key is compared by its new name, so that name is the hint.
647
+ unknown_key_message("#{prefix}#{k}", RENAMED_PRICE_KEYS.fetch(k, k), ModelPrice::KEYS, prefix: prefix)
619
648
  end
620
649
  end
621
650
 
@@ -853,6 +882,7 @@ module Samagotchi
853
882
  "hosts entry '#{name}'")
854
883
  llm_context_apply = LLMContextStrategy.parse_apply(raw_cfg.key?(LLMContextStrategy::APPLY_KEY) ? raw_cfg[LLMContextStrategy::APPLY_KEY] : raw_cfg[:llm_context_apply],
855
884
  "hosts entry '#{name}'")
885
+ models = HostModel.parse_map(raw_cfg.key?("models") ? raw_cfg["models"] : raw_cfg[:models], name)
856
886
  llm_context_budget = LLMContextStrategy.parse_budget(raw_cfg.key?(LLMContextStrategy::BUDGET_KEY) ? raw_cfg[LLMContextStrategy::BUDGET_KEY] : raw_cfg[:llm_context_budget_tokens],
857
887
  "hosts entry '#{name}'")
858
888
  unless first_token_timeout.nil? || (first_token_timeout.is_a?(Numeric) && !first_token_timeout.negative?)
@@ -920,8 +950,10 @@ module Samagotchi
920
950
  profile: profile.empty? ? nil : profile, first_token_timeout: first_token_timeout,
921
951
  vision: vision, sampling: sampling, thinking: thinking, remote: remote,
922
952
  window_tokens: window, llm_context_strategy: llm_context,
923
- llm_context_apply: llm_context_apply, llm_context_budget_tokens: llm_context_budget }
953
+ llm_context_apply: llm_context_apply, llm_context_budget_tokens: llm_context_budget,
954
+ models: models }
924
955
  end
956
+ warn_duplicate_host_models(normalized)
925
957
  end
926
958
 
927
959
  # If no hosts defined, synthesize "default" from server.host/port/transport.
@@ -941,6 +973,30 @@ module Samagotchi
941
973
  {}
942
974
  end
943
975
 
976
+ # hosts.<name>.models as written back (HostModel#to_config), nil when none.
977
+ def host_models_config(models)
978
+ models.values.to_h { |m| [m.id, m.to_config] } unless models.nil? || models.empty?
979
+ end
980
+ private_class_method :host_models_config
981
+
982
+ # Two hosts declaring one id under hosts.<name>.models: a bare id goes
983
+ # where HostRegistry#host_for_model sends it (the default host when it
984
+ # declares the id, else the first in hosts: order); say so once.
985
+ def warn_duplicate_host_models(hosts)
986
+ declared = Hash.new { |h, k| h[k] = [] }
987
+ hosts.each { |name, cfg| cfg[:models].each { |down, model| declared[down] << [name, model.id] } }
988
+ declared.each_value do |owners|
989
+ next if owners.size < 2
990
+
991
+ names = owners.map(&:first)
992
+ id = owners.first.last
993
+ goes = names.include?("default") ? "default" : "#{names.first} unless the default host lists it"
994
+ warn_once "Warning: #{names.map { |n| "hosts.#{n}.models" }.join(" and ")} #{names.size == 2 ? "both" : "all"} declare #{id}; " \
995
+ "a bare #{id} goes to #{goes}"
996
+ end
997
+ end
998
+ private_class_method :warn_duplicate_host_models
999
+
944
1000
  # The names (lowercased) of the hosts entries with enabled: false, which
945
1001
  # hosts_config leaves out. Read from the same source: config.yml's
946
1002
  # hosts:, or SAMAGOTCHI_HOSTS_JSON in a worker.
@@ -1070,7 +1126,8 @@ module Samagotchi
1070
1126
  "remote" => v[:remote], "window_tokens" => v[:window_tokens],
1071
1127
  "llm_context_strategy" => v[:llm_context_strategy]&.map(&:to_s),
1072
1128
  "llm_context_apply" => v[:llm_context_apply]&.to_s,
1073
- "llm_context_budget_tokens" => v[:llm_context_budget_tokens]).compact
1129
+ "llm_context_budget_tokens" => v[:llm_context_budget_tokens],
1130
+ "models" => host_models_config(v[:models])).compact
1074
1131
  end
1075
1132
  # Disabled hosts travel as just that, so a worker refuses "box:x"
1076
1133
  # the way its parent does instead of sending it to the default host.
@@ -980,6 +980,7 @@ final class PanelController: NSObject, NSWindowDelegate {
980
980
  let kittyText = windows.isEmpty ? "" : Kitty.text(context: model.hasContext ? model.text : "", message: model.trimmedPrompt,
981
981
  imagePaths: ImageIntake.keepForPaste(images), pasteOnly: kind == .paste)
982
982
 
983
+ let open = opens
983
984
  let group = DispatchGroup()
984
985
  var chiOutcome: SendOutcome?
985
986
  var kittyOutcomes: [(KittyWindow, KittyError?)] = []
@@ -1004,6 +1005,9 @@ final class PanelController: NSObject, NSWindowDelegate {
1004
1005
  // another try, unless the panel was closed or reopened meanwhile.
1005
1006
  if ok { self.model.images.removeAll { images.contains($0) } }
1006
1007
  ImageIntake.delete(images.filter { !self.model.images.contains($0) })
1008
+ // Reopened meanwhile (as for a broadcast): the new open keeps its
1009
+ // message and phase, and isn't closed by this send's success.
1010
+ guard self.opens == open else { return }
1007
1011
  var parts: [String] = []
1008
1012
  if let chiOutcome { parts.append(chiOutcome.message) }
1009
1013
  if !pasted.isEmpty {
@@ -1014,7 +1018,7 @@ final class PanelController: NSObject, NSWindowDelegate {
1014
1018
  if ok {
1015
1019
  self.model.phase = .sent
1016
1020
  DispatchQueue.main.asyncAfter(deadline: .now() + (chiOutcome?.linger ?? 1.0)) { [weak self] in
1017
- if self?.model.phase == .sent { self?.panel.close() }
1021
+ if self?.opens == open, self?.model.phase == .sent { self?.panel.close() }
1018
1022
  }
1019
1023
  } else {
1020
1024
  self.model.phase = .failed
@@ -1022,8 +1026,8 @@ final class PanelController: NSObject, NSWindowDelegate {
1022
1026
  }
1023
1027
  }
1024
1028
 
1025
- /// Counts the panel's opens: a broadcast that ends after a reopen leaves
1026
- /// the new open alone.
1029
+ /// Counts the panel's opens: a send or broadcast that ends after a
1030
+ /// reopen leaves the new open alone.
1027
1031
  private var opens = 0
1028
1032
 
1029
1033
  /// The Everyone row: `chi broadcast` with the note (the source field is
@@ -945,13 +945,16 @@ module Samagotchi
945
945
  # context window, the served model and (on a native host) the prompt
946
946
  # profile come from the effective model's server, so this may make one
947
947
  # short /props GET; the cheap #session_state_snapshot never does.
948
+ # price_host: the effective model's host, whose hosts.<name>.models
949
+ # prices the cost estimate names.
948
950
  # @return [Hash] @metrics.snapshot, filled in
949
951
  def stats_snapshot
950
952
  snapshot = @metrics.snapshot
951
953
  target = @host_registry.resolve(@effective_model_name)
952
954
  served, served_for = served_model_for(snapshot, target: target)
953
- snapshot = snapshot.merge(served_model: served, served_model_for: served_for, llm_context: llm_context_summary,
954
- prompt_notes: prompt_notes.map(&:to_h))
955
+ snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
956
+ served_expected_by: served_expected_by(served, target: target), llm_context: llm_context_summary,
957
+ prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
955
958
  unless snapshot.dig(:context, :window_tokens)
956
959
  window = current_context_window(target)
957
960
  if window
@@ -1045,6 +1048,18 @@ module Samagotchi
1045
1048
  served_model_for(@metrics.served_report, target: probe ? @host_registry.resolve(@effective_model_name) : nil)
1046
1049
  end
1047
1050
 
1051
+ # The host whose hosts.<name>.models.<id>.served names +served+ for the
1052
+ # current model (a gateway's round-robin targets): not a mismatch to
1053
+ # warn about. nil when none does.
1054
+ # @return [String, nil]
1055
+ def served_expected_by(served, target: nil)
1056
+ ServedModel.expected_by(target || @host_registry.resolve(@effective_model_name), served)
1057
+ rescue StandardError
1058
+ nil
1059
+ end
1060
+
1061
+ def served_expected?(served) = !served_expected_by(served).nil?
1062
+
1048
1063
  # Read-only snapshot of the engine's view of the current session plus the
1049
1064
  # live event sequence. Cheap primitive used by the bridge's reconnect-too-
1050
1065
  # old reset marker and the GET /session/:id/state read surface. Orthogonal
@@ -1065,6 +1080,8 @@ module Samagotchi
1065
1080
  # :served_model, :served_model_for [String, nil] what a generation of
1066
1081
  # that model reported serving, and the name asked (#served_model
1067
1082
  # without the probe)
1083
+ # :served_expected_by [String, nil] the host whose models: entry names
1084
+ # that served model under served: (#served_expected_by)
1068
1085
  # :recap_enabled [Boolean] whether an idle recap is configured, with
1069
1086
  # :recap_min_user_turns and :recap_inactivity_seconds (nil when not)
1070
1087
  def session_state_snapshot
@@ -1085,6 +1102,7 @@ module Samagotchi
1085
1102
  model_name: effective_model_ref,
1086
1103
  served_model: served_pair[0],
1087
1104
  served_model_for: served_pair[1],
1105
+ served_expected_by: served_expected_by(served_pair[0]),
1088
1106
  context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
1089
1107
  llm_context: llm_context_summary,
1090
1108
  recap_enabled: !@recap.nil?,
@@ -1430,8 +1448,6 @@ module Samagotchi
1430
1448
  @question_desk.request(payload)
1431
1449
  end
1432
1450
 
1433
- QUESTION_DISMISSED_NOTE = QuestionDesk::DISMISSED_NOTE
1434
-
1435
1451
  # Open a question for the UIs and wait for its answer
1436
1452
  # (QuestionDesk#open_question: BLOCKS until answered or cancelled).
1437
1453
  # @param fields [Hash] question:, options:, header:, multi_select:, allow_freeform:, …
@@ -1824,7 +1840,7 @@ module Samagotchi
1824
1840
  @turn_thinking = [thinking_level(thinking_target), thinking_target]
1825
1841
  announce_thinking_level(*@turn_thinking)
1826
1842
  LLM::TurnSettings.new(vision: vision, sampling: sampling, thinking: @turn_thinking.first, model_name: nil,
1827
- window_setting: nil, llm_context: nil)
1843
+ window_setting: nil, llm_context: nil, price: nil)
1828
1844
  end
1829
1845
  private :turn_settings
1830
1846
 
@@ -1918,7 +1934,7 @@ module Samagotchi
1918
1934
  # turn's model, not the last native one.
1919
1935
  explained = llm_context_explained
1920
1936
  @kernel.turn_settings = turn.settings.with(model_name: bare_for_backend, window_setting: turn_window_setting,
1921
- llm_context: explained&.resolved)
1937
+ llm_context: explained&.resolved, price: turn_price)
1922
1938
  log_llm_context(explained)
1923
1939
 
1924
1940
  turn.limit = @no_interrupt ? IterationLimit.for(no_interrupt: true) : max_iterations || IterationLimit.for
@@ -2549,6 +2565,12 @@ module Samagotchi
2549
2565
  when :generation_started
2550
2566
  @generation_thought = nil
2551
2567
  watch&.started(event[:iteration])
2568
+ when :generation_retrying
2569
+ # A dropped stream's step asked again: it streams from the start.
2570
+ if event[:restarted]
2571
+ @generation_thought = nil
2572
+ watch&.started(event[:iteration])
2573
+ end
2552
2574
  when :generation_chunk then note_generation_thought(progress[:thinking])
2553
2575
  end
2554
2576
  # The chat loop asked again without the thinking fields: a notice,
@@ -2566,13 +2588,14 @@ module Samagotchi
2566
2588
  end
2567
2589
 
2568
2590
  # A :generation_completed with the generation's decode speed (`speed:`
2569
- # {decode_tps:, source:}, nil without one) and the session's running
2570
- # token totals (`tokens:`, the snapshot's block): the web's info bar
2591
+ # {decode_tps:, source:}, nil without one), the session's running
2592
+ # token totals (`tokens:`, the snapshot's block) and whether its served
2593
+ # model is an expected one (`served_expected_by:`, its host): the web's info bar
2571
2594
  # reads them from the stream. SessionMetrics closes the generation here,
2572
2595
  # so the event it gets next finds it closed.
2573
2596
  def with_generation_report(event)
2574
2597
  report = @metrics.finish_generation
2575
- event.merge(speed: report.speed&.to_h, tokens: report.tokens)
2598
+ event.merge(speed: report.speed&.to_h, tokens: report.tokens, served_expected_by: served_expected_by(event[:served_model]))
2576
2599
  rescue StandardError
2577
2600
  event
2578
2601
  end
@@ -2638,6 +2661,15 @@ module Samagotchi
2638
2661
  nil
2639
2662
  end
2640
2663
 
2664
+ # The effective model's price on its host (hosts.<name>.models.<id>.price),
2665
+ # by the requested id, never the served one; read per turn as the window is.
2666
+ def turn_price
2667
+ target = @host_registry.resolve(@effective_model_name)
2668
+ target.entry.models&.dig(target.bare_model.to_s.strip.downcase)&.price
2669
+ rescue StandardError
2670
+ nil
2671
+ end
2672
+
2641
2673
  # The LLM context strategy the next turn runs under: the session's own
2642
2674
  # (Session#llm_context), else the effective model's (models:/hosts:
2643
2675
  # llm_context_strategy), else llm_context.strategy; read per turn as
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "model_price"
4
+
5
+ module Samagotchi
6
+ # A model a host serves, declared under hosts.<name>.models: whatever the
7
+ # host's /v1/models says (a gateway's round-robin ids it never lists). A
8
+ # declared id is known on its host: no "doesn't list" warning, no re-list,
9
+ # and a bare id routes there as if the host listed it
10
+ # (HostRegistry#host_for_model).
11
+ #
12
+ # id: the id as written (keys are matched downcased); price: its
13
+ # ModelPrice on this host, nil when none; served: the models the host may
14
+ # answer it with (a gateway's round-robin targets), exact ids as written
15
+ # or "any", nil when none: no served-model warning for those (#serves?).
16
+ HostModel = Data.define(:id, :price, :served) do
17
+ def initialize(id:, price: nil, served: nil) = super
18
+
19
+ # hosts.<name>.models as written: a map of ids (each nil or a map), or
20
+ # a plain list of ids. Anything else warns once and is skipped.
21
+ # @param raw [Hash, Array, nil]
22
+ # @param host_name [String] for the warnings
23
+ # @return [Hash{String => HostModel}] by downcased id, in written order
24
+ def self.parse_map(raw, host_name)
25
+ return {} if raw.nil?
26
+
27
+ where = "hosts.#{host_name}.models"
28
+ pairs = case raw
29
+ when Hash then raw.to_a
30
+ when Array then raw.map { |id| [id, nil] }
31
+ else
32
+ warn_once "Warning: #{where} must be a map or a list of model ids; ignored"
33
+ return {}
34
+ end
35
+ pairs.each_with_object({}) do |(id, entry), models|
36
+ id = id.to_s.strip if id.is_a?(String) || id.is_a?(Symbol)
37
+ unless id.is_a?(String) && !id.empty?
38
+ warn_once "Warning: #{where}: #{id.inspect} is not a model id; ignored"
39
+ next
40
+ end
41
+ unless entry.nil? || entry.is_a?(Hash)
42
+ warn_once "Warning: #{where}.#{id} must be empty or a mapping; ignored"
43
+ next
44
+ end
45
+ value = ->(key) { entry && (entry.key?(key) ? entry[key] : entry[key.to_sym]) }
46
+ price = ModelPrice.parse(value.call("price"), "#{where}.#{id}")
47
+ models[id.downcase] ||= new(id: id, price: price, served: parse_served(value.call("served"), "#{where}.#{id}"))
48
+ end
49
+ end
50
+
51
+ # served: a list of ids, or "any"; anything else warns once and is unset.
52
+ def self.parse_served(raw, where)
53
+ return nil if raw.nil?
54
+ return "any" if raw.is_a?(String) && raw.strip.casecmp?("any")
55
+
56
+ ids = raw.is_a?(Array) && raw.all? { |id| id.is_a?(String) && !id.strip.empty? } ? raw.map(&:strip) : nil
57
+ warn_once "Warning: #{where}.served must be a list of model ids or any; ignored" unless ids
58
+ ids
59
+ end
60
+ private_class_method :parse_served
61
+
62
+ # Whether the host answering this model with +name+ is expected
63
+ # (served: lists it, any case, or says any).
64
+ def serves?(name)
65
+ return false if served.nil? || name.to_s.strip.empty?
66
+
67
+ served == "any" || served.any? { |id| id.casecmp?(name.to_s.strip) }
68
+ end
69
+
70
+ # config.rb requires this file, so config is required here only when a
71
+ # warning needs it (this file loaded alone).
72
+ def self.warn_once(message)
73
+ require_relative "config"
74
+ ConfigFile.warn_once(message)
75
+ end
76
+ private_class_method :warn_once
77
+
78
+ # One host's rows as the listings show them, declared ids first, then
79
+ # the listed ones in the host's order (see Row). An id both declared and
80
+ # listed is one row. Pure: +results+ (list_all_models') is only read.
81
+ # A host that errored keeps only its declared ids, marked unavailable
82
+ # (still pickable; the listings warn it's unreachable); [] when it
83
+ # declares none.
84
+ # @param results [Hash{String => Hash}] host name => {models: [ModelInfo], error:}
85
+ # @param entries [Hash{String => HostRegistry::HostEntry}]
86
+ # @return [Hash{String => Array<Row>}] for every host in +results+
87
+ def self.rows(results, entries)
88
+ results.each_with_object({}) do |(name, data), out|
89
+ declared = entries[name]&.models || {}
90
+ if data[:error]
91
+ out[name] = declared.values.map { |m| HostModel::Row.new(id: m.id, configured: true, info: nil, unavailable: true) }
92
+ next
93
+ end
94
+
95
+ infos = Array(data[:models])
96
+ by_id = infos.group_by { |info| info.id.to_s.downcase }
97
+ out[name] = declared.values.map do |m|
98
+ info = by_id[m.id.downcase]&.first
99
+ # A listed id keeps the host's spelling: a case-sensitive server takes only that.
100
+ HostModel::Row.new(id: info ? info.id.to_s : m.id, configured: true, info: info)
101
+ end +
102
+ infos.reject { |info| declared.key?(info.id.to_s.downcase) }
103
+ .map { |info| HostModel::Row.new(id: info.id.to_s, configured: false, info: info) }
104
+ end
105
+ end
106
+
107
+ # The entry as written back to config (SAMAGOTCHI_HOSTS_JSON for
108
+ # workers): nil for a bare id.
109
+ def to_config
110
+ config = { "price" => price&.to_config, "served" => served }.compact
111
+ config unless config.empty?
112
+ end
113
+ end
114
+
115
+ # A listing row (HostModel.rows): id as the host lists it, else as
116
+ # declared;
117
+ # configured: declared under hosts.<name>.models; info: the host's
118
+ # ModelInfo for it, nil when only declared; unavailable: the host failed
119
+ # to list its models; only its declared ids are shown.
120
+ HostModel::Row = Data.define(:id, :configured, :info, :unavailable) do
121
+ def initialize(id:, configured:, info:, unavailable: false) = super
122
+ end
123
+ end
@@ -39,10 +39,12 @@ module Samagotchi
39
39
  # window_tokens: the configured context window (ContextWindow.setting), nil when unset;
40
40
  # llm_context_strategy: the configured layers (LLMContextStrategy), nil when unset;
41
41
  # llm_context_apply: the configured apply rule (LLMContextStrategy), nil when unset;
42
- # llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset.
42
+ # llm_context_budget_tokens: the configured context budget (LLMContextStrategy), nil when unset;
43
+ # models: the ids the host serves whatever it lists, {downcased id => HostModel} ({} when none).
43
44
  HostEntry = Struct.new(:name, :host, :port, :transport, :client, :api, :scheme, :url, :api_key_env, :profile,
44
45
  :first_token_timeout, :vision, :sampling, :thinking, :remote, :window_tokens,
45
- :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens, keyword_init: true) do
46
+ :llm_context_strategy, :llm_context_apply, :llm_context_budget_tokens, :models,
47
+ keyword_init: true) do
46
48
  # Talks the OpenAI chat API (the chat loop); nil and raw apis use the
47
49
  # raw-prompt loop.
48
50
  def chat? = api == :openai
@@ -104,7 +106,7 @@ module Samagotchi
104
106
  vision: cfg[:vision], sampling: cfg[:sampling], thinking: cfg[:thinking],
105
107
  remote: cfg[:remote], window_tokens: cfg[:window_tokens],
106
108
  llm_context_strategy: cfg[:llm_context_strategy], llm_context_apply: cfg[:llm_context_apply],
107
- llm_context_budget_tokens: cfg[:llm_context_budget_tokens])
109
+ llm_context_budget_tokens: cfg[:llm_context_budget_tokens], models: cfg[:models] || {})
108
110
  entry.client = Client.new(host: cfg[:host], port: cfg[:port], transport: transport, scheme: cfg[:scheme],
109
111
  first_token_timeout: entry.first_token_limit, name: entry.name,
110
112
  api_key_env: entry.api_key_env, env: env)
@@ -116,7 +118,8 @@ module Samagotchi
116
118
  host = "localhost" if host.empty?
117
119
  port = Config.get("server.port").to_i
118
120
  port = 8080 if port <= 0
119
- @entries["default"] = HostEntry.new(name: "default", host: host, port: port, transport: nil, client: Client.new(host: host, port: port, name: "default"))
121
+ @entries["default"] = HostEntry.new(name: "default", host: host, port: port, transport: nil, models: {},
122
+ client: Client.new(host: host, port: port, name: "default"))
120
123
  end
121
124
  @mutex = Mutex.new
122
125
  @cache = nil
@@ -195,18 +198,21 @@ module Samagotchi
195
198
 
196
199
  # The host a model name, alias or host:model ref goes to and the model
197
200
  # id it is sent as (ModelRef: one alias pass). A ref that names a host
198
- # goes there. A bare id goes to the default host when its cached list
199
- # has it, else to the first host in hosts: order whose list has it,
200
- # else to the default host (before /models nothing is listed: no
201
+ # goes there. A bare id goes to the hosts whose cached list has it or
202
+ # that declare it (hosts.<name>.models, known before any /models): the
203
+ # default host when it is one of them, else the first in hosts: order,
204
+ # else the default host (before /models nothing is listed: no
201
205
  # discovery here, the latency budget). Never by a substring.
202
206
  def host_for_model(raw_model)
203
207
  ref = model_ref(raw_model)
204
208
  # ModelRef names a host only when it is one of ours
205
209
  return [find_entry(ref.host_name), ref.id] if ref.host_name
206
210
 
207
- listed = @mutex.synchronize { @model_index }&.fetch(ref.id.to_s.strip.downcase, nil) || []
211
+ down = ref.id.to_s.strip.downcase
212
+ listed = @mutex.synchronize { @model_index }&.fetch(down, nil) || []
213
+ candidates = @entries.values.select { |e| listed.include?(e.name) || e.models&.key?(down) }.map(&:name)
208
214
  default = default_entry
209
- name = listed.include?(default.name) ? default.name : listed.first
215
+ name = candidates.include?(default.name) ? default.name : candidates.first
210
216
  [(name && find_entry(name)) || default, ref.id]
211
217
  end
212
218
 
@@ -316,7 +322,7 @@ module Samagotchi
316
322
  save_model_list(name, models)
317
323
  rescue StandardError => e
318
324
  Log.warn(:model, "list_failed", host: name, error: e.class.name, msg: e.message.to_s[0, 500])
319
- data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: e.message }
325
+ data = { host: entry.host, port: entry.port, transport: entry.transport, models: [], error: list_error(e) }
320
326
  end
321
327
  results_mutex.synchronize { results[name] = data }
322
328
  end
@@ -368,6 +374,11 @@ module Samagotchi
368
374
 
369
375
  private
370
376
 
377
+ # A listing error as text without the host's name (a provider error's
378
+ # #detail: "connection refused", "request failed after …"): every line
379
+ # that shows it names the host itself.
380
+ def list_error(error) = error.is_a?(LLM::ProviderError) ? error.detail : error.message
381
+
371
382
  # Save a host's just-listed models on disk, for the processes that spawn
372
383
  # a worker without listing (`chi send --new --model`, delegate):
373
384
  # ModelListStore is what they check an id against.
@@ -353,7 +353,8 @@ module Samagotchi
353
353
  # thought or a tool call doesn't leave the next one "inside" it.
354
354
  stream_splitter = ThoughtStreamSplitter.for_profile(@profile)
355
355
  emit(turn, type: :generation_started, iteration: turn.iteration, context_window_tokens: request.window.tokens,
356
- context_window_source: request.window.source, profile: @profile.name, profile_source: @profile_source)
356
+ context_window_source: request.window.source, profile: @profile.name, profile_source: @profile_source,
357
+ price: @turn_settings.price&.to_h)
357
358
  # This generation's own server counts (the run-long ones in the
358
359
  # context tracker can be an earlier one's), and the thinking it
359
360
  # streamed, for the log (a stuck thinking generation shows as