samagotchi 0.50.0 → 0.52.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -1
  3. data/README.md +1 -1
  4. data/bin/chi +11 -3
  5. data/docs/architecture.md +1 -1
  6. data/docs/cli.md +40 -11
  7. data/docs/configuration.md +63 -21
  8. data/docs/hooks.md +3 -2
  9. data/docs/internals/engine.md +4 -2
  10. data/docs/memory.md +11 -0
  11. data/docs/plugins.md +2 -1
  12. data/docs/sessions.md +10 -5
  13. data/docs/testing.md +6 -1
  14. data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
  15. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +3 -1
  17. data/lib/samagotchi/bridge.rb +29 -17
  18. data/lib/samagotchi/bridge_client.rb +3 -1
  19. data/lib/samagotchi/broadcast/recipients.rb +1 -2
  20. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  21. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  22. data/lib/samagotchi/children_status.rb +1 -1
  23. data/lib/samagotchi/commands/registry.rb +2 -1
  24. data/lib/samagotchi/config.rb +50 -182
  25. data/lib/samagotchi/config_text_edit.rb +8 -32
  26. data/lib/samagotchi/context_sources.rb +0 -7
  27. data/lib/samagotchi/context_window.rb +2 -2
  28. data/lib/samagotchi/continue_offer.rb +5 -4
  29. data/lib/samagotchi/cut_policy.rb +0 -1
  30. data/lib/samagotchi/delivery.rb +56 -0
  31. data/lib/samagotchi/engine.rb +48 -17
  32. data/lib/samagotchi/engine_peers.rb +1 -0
  33. data/lib/samagotchi/host_config.rb +183 -0
  34. data/lib/samagotchi/host_registry.rb +21 -13
  35. data/lib/samagotchi/kernel_loop.rb +28 -2
  36. data/lib/samagotchi/last_turn.rb +48 -0
  37. data/lib/samagotchi/live_versions.rb +0 -6
  38. data/lib/samagotchi/llm/native_tool_normalizer.rb +2 -7
  39. data/lib/samagotchi/llm_context_override.rb +3 -22
  40. data/lib/samagotchi/llm_context_strategy.rb +50 -16
  41. data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
  42. data/lib/samagotchi/model_profile.rb +4 -4
  43. data/lib/samagotchi/model_settings.rb +46 -0
  44. data/lib/samagotchi/plugin/context.rb +3 -3
  45. data/lib/samagotchi/plugin/sessions.rb +2 -2
  46. data/lib/samagotchi/queued_command.rb +35 -0
  47. data/lib/samagotchi/reminder_store.rb +0 -27
  48. data/lib/samagotchi/reply_wait.rb +11 -13
  49. data/lib/samagotchi/sampling_settings.rb +4 -4
  50. data/lib/samagotchi/self_report.rb +8 -5
  51. data/lib/samagotchi/send_command.rb +92 -21
  52. data/lib/samagotchi/session.rb +45 -24
  53. data/lib/samagotchi/session_commands.rb +41 -3
  54. data/lib/samagotchi/session_inbox.rb +36 -7
  55. data/lib/samagotchi/session_manager.rb +21 -10
  56. data/lib/samagotchi/session_metrics.rb +25 -6
  57. data/lib/samagotchi/session_setup.rb +30 -0
  58. data/lib/samagotchi/sessions_command.rb +1 -1
  59. data/lib/samagotchi/steer_cut.rb +21 -10
  60. data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
  61. data/lib/samagotchi/terminal_ui/attached_loop.rb +48 -1
  62. data/lib/samagotchi/terminal_ui/formatting.rb +69 -4
  63. data/lib/samagotchi/terminal_ui.rb +56 -8
  64. data/lib/samagotchi/thinking.rb +67 -7
  65. data/lib/samagotchi/thinking_command.rb +72 -0
  66. data/lib/samagotchi/tool_activity.rb +3 -0
  67. data/lib/samagotchi/tool_runner.rb +2 -0
  68. data/lib/samagotchi/tools/builtin_calls.rb +33 -0
  69. data/lib/samagotchi/tools/builtins.rb +7 -2
  70. data/lib/samagotchi/tools/peers.rb +3 -2
  71. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  72. data/lib/samagotchi/version.rb +1 -1
  73. data/lib/samagotchi/vision_support.rb +4 -3
  74. data/lib/samagotchi/web/app.rb +59 -7
  75. data/lib/samagotchi/web/message_parts.rb +3 -0
  76. data/lib/samagotchi/web/public/activity.js +6 -0
  77. data/lib/samagotchi/web/public/app.js +199 -48
  78. data/lib/samagotchi/web/public/data.js +8 -4
  79. data/lib/samagotchi/web/public/format.js +8 -4
  80. data/lib/samagotchi/web/public/index.html +25 -20
  81. data/lib/samagotchi/web/public/kept_commands.js +52 -0
  82. data/lib/samagotchi/web/public/model_pick.js +3 -1
  83. data/lib/samagotchi/web/public/session_title.js +26 -0
  84. data/lib/samagotchi/web/public/strip.js +4 -6
  85. data/lib/samagotchi/web/public/thinking_chip.js +169 -0
  86. data/lib/samagotchi/web/public/timing.js +15 -0
  87. data/lib/samagotchi/web/public/turn_events.js +26 -2
  88. data/lib/samagotchi/web/public/turn_html.js +3 -1
  89. data/lib/samagotchi/web/public/turn_view.js +328 -310
  90. data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
  91. data/lib/samagotchi/web/session_summary.rb +1 -1
  92. data/lib/samagotchi/worker.rb +44 -43
  93. data/lib/samagotchi/worker_inbound.rb +10 -7
  94. data/lib/samagotchi/worker_wakes.rb +0 -2
  95. data/lib/samagotchi/yaml_lines.rb +72 -0
  96. data/lib/samagotchi.rb +1 -0
  97. metadata +13 -1
@@ -15,6 +15,11 @@ require_relative "host_model"
15
15
  module Samagotchi
16
16
  # Parses the thinking: levels of host and model entries (it needs Config).
17
17
  autoload :Thinking, File.expand_path("thinking", __dir__)
18
+ # One hosts: entry; it needs ConfigFile's readers.
19
+ autoload :HostConfig, File.expand_path("host_config", __dir__)
20
+ # One models: entry, and one field found in them; they need ConfigFile's readers.
21
+ autoload :ModelSettings, File.expand_path("model_settings", __dir__)
22
+ autoload :ModelSetting, File.expand_path("model_settings", __dir__)
18
23
 
19
24
  # Unified configuration registry implementing the implicit convention:
20
25
  # ENV SAMAGOTCHI_ATTR (UPPER + prefix + _ = nesting)
@@ -170,10 +175,10 @@ module Samagotchi
170
175
  Entry.new(key: "llm_context.stale_edits", yaml_path: %w[llm_context stale_edits], type: :bool, default: false, expose: %i[env config]),
171
176
  # The steps (tool batches) back whose edited files' reads stale never stubs.
172
177
  Entry.new(key: "llm_context.protect_steps", yaml_path: %w[llm_context protect_steps], type: :integer, default: 3, expose: %i[env config]),
173
- # A soft context budget in tokens: the context status bands (and the forget layer's offers) count against it
174
- # instead of the window; unset or 0: off. models.<key>.llm_context_budget_tokens and
175
- # hosts.<name>.llm_context_budget_tokens come first.
176
- Entry.new(key: "llm_context.budget_tokens", yaml_path: %w[llm_context budget_tokens], type: :integer, default: nil, expose: %i[env config]),
178
+ # A soft context budget in tokens (64000, 64k; 4k to 10M): the context status bands (and the forget layer's
179
+ # offers) count against it instead of the window; unset, 0 or off: off. models.<key>.llm_context_budget_tokens
180
+ # and hosts.<name>.llm_context_budget_tokens come first. Kept as written: LLMContextStrategy.parse_budget reads it.
181
+ Entry.new(key: "llm_context.budget_tokens", yaml_path: %w[llm_context budget_tokens], type: :string, default: nil, expose: %i[env config]),
177
182
  # The policy sentence forget_outputs' description carries (the forget layer); blank: none.
178
183
  Entry.new(key: "llm_context.policy", yaml_path: %w[llm_context policy], type: :string, default: LLMContextStrategy::DEFAULT_POLICY, expose: %i[env config]),
179
184
 
@@ -841,6 +846,10 @@ module Samagotchi
841
846
  HOST_NAME_RE = /\A[a-z0-9][a-z0-9._-]*\z/i
842
847
  ENV_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_]*\z/
843
848
 
849
+ # config.yml's hosts: (or a worker's SAMAGOTCHI_HOSTS_JSON) by lowercased
850
+ # name; an entry that is disabled or invalid (warned) is left out. No
851
+ # entry left: "default", from server.host/port/transport.
852
+ # @return [Hash{String => HostConfig}]
844
853
  def hosts_config(env: ENV, path: global_path(env: env))
845
854
  data = read_yaml(env: env, path: path)
846
855
  raw_hosts = hosts_source(data, env)
@@ -848,143 +857,38 @@ module Samagotchi
848
857
  normalized = {}
849
858
  if raw_hosts.is_a?(Hash)
850
859
  raw_hosts.each do |raw_name, raw_cfg|
851
- name = raw_name.to_s.strip
852
- next if name.empty?
853
-
854
- unless name.match?(HOST_NAME_RE)
855
- warn_once "Warning: ignoring hosts entry '#{name}': must match /[a-z0-9][a-z0-9._-]*/i"
856
- next
857
- end
858
- lowered = name.downcase
859
- unless raw_cfg.is_a?(Hash)
860
- warn_once "Warning: ignoring hosts entry '#{name}': expected mapping"
861
- next
862
- end
863
- host = raw_cfg["host"] || raw_cfg[:host]
864
- port = raw_cfg["port"] || raw_cfg[:port]
865
- transport = raw_cfg["transport"] || raw_cfg[:transport]
866
- api = raw_cfg["api"] || raw_cfg[:api]
867
- next if host_disabled?(raw_cfg)
868
-
869
- url = (raw_cfg["url"] || raw_cfg[:url]).to_s.strip
870
- api_key_env = (raw_cfg["api_key_env"] || raw_cfg[:api_key_env]).to_s.strip
871
- # Kept as written; ModelProfile.resolve warns about an unknown one.
872
- profile = (raw_cfg["profile"] || raw_cfg[:profile]).to_s.strip.downcase
873
- first_token_timeout = raw_cfg.key?("first_token_timeout") ? raw_cfg["first_token_timeout"] : raw_cfg[:first_token_timeout]
874
- vision = ConfigFile.vision_flag(raw_cfg.key?("vision") ? raw_cfg["vision"] : raw_cfg[:vision], "hosts entry '#{name}'")
875
- remote = ConfigFile.bool_flag(raw_cfg.key?("remote") ? raw_cfg["remote"] : raw_cfg[:remote], "hosts entry '#{name}'",
876
- "remote")
877
- window = ConfigFile.window_tokens(raw_cfg.key?("window_tokens") ? raw_cfg["window_tokens"] : raw_cfg[:window_tokens],
878
- "hosts entry '#{name}'")
879
- sampling = ConfigFile.sampling_map(raw_cfg.key?("sampling") ? raw_cfg["sampling"] : raw_cfg[:sampling], "hosts entry '#{name}'")
880
- thinking = Thinking.level(raw_cfg.key?("thinking") ? raw_cfg["thinking"] : raw_cfg[:thinking], "hosts entry '#{name}'")
881
- llm_context = LLMContextStrategy.parse(raw_cfg.key?(LLMContextStrategy::KEY) ? raw_cfg[LLMContextStrategy::KEY] : raw_cfg[:llm_context_strategy],
882
- "hosts entry '#{name}'")
883
- llm_context_apply = LLMContextStrategy.parse_apply(raw_cfg.key?(LLMContextStrategy::APPLY_KEY) ? raw_cfg[LLMContextStrategy::APPLY_KEY] : raw_cfg[:llm_context_apply],
884
- "hosts entry '#{name}'")
885
- models = HostModel.parse_map(raw_cfg.key?("models") ? raw_cfg["models"] : raw_cfg[:models], name)
886
- llm_context_budget = LLMContextStrategy.parse_budget(raw_cfg.key?(LLMContextStrategy::BUDGET_KEY) ? raw_cfg[LLMContextStrategy::BUDGET_KEY] : raw_cfg[:llm_context_budget_tokens],
887
- "hosts entry '#{name}'")
888
- unless first_token_timeout.nil? || (first_token_timeout.is_a?(Numeric) && !first_token_timeout.negative?)
889
- warn_once "Warning: hosts entry '#{name}': first_token_timeout must be seconds (0 = off); using the default"
890
- first_token_timeout = nil
891
- end
892
- unless api_key_env.empty? || api_key_env.match?(ENV_NAME_RE)
893
- warn_once "Warning: ignoring hosts entry '#{name}': api_key_env must be an environment variable name"
894
- next
895
- end
896
- scheme = "http"
897
- unless url.empty?
898
- unless host.to_s.strip.empty? && port.to_s.strip.empty?
899
- warn_once "Warning: ignoring hosts entry '#{name}': give url or host/port, not both"
900
- next
901
- end
902
- uri = begin
903
- URI.parse(url)
904
- rescue URI::InvalidURIError
905
- nil
906
- end
907
- unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
908
- warn_once "Warning: ignoring hosts entry '#{name}': url must be an http(s) URL"
909
- next
910
- end
911
- host = uri.host
912
- port = uri.port
913
- scheme = uri.scheme
914
- url = url.chomp("/")
915
- end
916
- host = host.to_s.strip
917
- if host.empty?
918
- warn_once "Warning: ignoring hosts entry '#{name}': host is required"
919
- next
920
- end
921
- port_val = port.to_s.strip.empty? ? 8080 : port.to_i
922
- if port_val <= 0 || port_val > 65_535
923
- warn_once "Warning: ignoring hosts entry '#{name}': invalid port"
924
- next
925
- end
926
- transport_val = transport.to_s.strip.downcase
927
- if transport_val.empty?
928
- transport_val = nil
929
- elsif !VALID_TRANSPORTS_FOR_CONFIG.include?(transport_val)
930
- warn_once "Warning: ignoring hosts entry '#{name}': unknown transport '#{transport_val}'"
931
- next
932
- end
933
- api_val = api.to_s.strip.downcase
934
- if api_val.empty?
935
- api_val = nil
936
- elsif !VALID_APIS_FOR_CONFIG.include?(api_val)
937
- warn_once "Warning: ignoring hosts entry '#{name}': unknown api '#{api_val}'"
938
- next
939
- elsif VALID_TRANSPORTS_FOR_CONFIG.include?(api_val)
940
- # A raw-prompt api is the transport; a different transport contradicts it.
941
- if transport_val && transport_val != api_val
942
- warn_once "Warning: ignoring hosts entry '#{name}': api '#{api_val}' conflicts with transport '#{transport_val}'"
943
- next
944
- end
945
- transport_val = api_val
946
- end
947
- normalized[lowered] = { name: lowered, host: host, port: port_val, transport: transport_val ? transport_val.to_sym : nil,
948
- api: api_val&.to_sym, original_name: name, scheme: scheme,
949
- url: url.empty? ? nil : url, api_key_env: api_key_env.empty? ? nil : api_key_env,
950
- profile: profile.empty? ? nil : profile, first_token_timeout: first_token_timeout,
951
- vision: vision, sampling: sampling, thinking: thinking, remote: remote,
952
- window_tokens: window, llm_context_strategy: llm_context,
953
- llm_context_apply: llm_context_apply, llm_context_budget_tokens: llm_context_budget,
954
- models: models }
860
+ host = HostConfig.parse(raw_name, raw_cfg)
861
+ normalized[host.name] = host if host
955
862
  end
956
863
  warn_duplicate_host_models(normalized)
957
864
  end
958
-
959
- # If no hosts defined, synthesize "default" from server.host/port/transport.
960
- if normalized.empty?
961
- opts = { file_data: data, env: env, cli_overrides: Samagotchi::Config.cli_overrides }
962
- default_host = Samagotchi::Config.resolve("server.host", **opts).to_s.strip
963
- default_host = "localhost" if default_host.empty?
964
- default_port = Samagotchi::Config.resolve("server.port", **opts).to_i
965
- default_port = 8080 if default_port <= 0 || default_port > 65_535
966
- transport_sym = Samagotchi::Config.resolve_with_origin("server.transport", **opts).then do |value, origin|
967
- origin == :default ? nil : value.to_sym
968
- end
969
- normalized["default"] = { name: "default", host: default_host, port: default_port, transport: transport_sym, original_name: "default" }
970
- end
865
+ normalized["default"] = default_host_config(data, env) if normalized.empty?
971
866
  normalized
972
- rescue StandardError
867
+ rescue StandardError => e
868
+ Log.error(:config, "hosts_config_failed", error: e.class.name, message: e.message)
973
869
  {}
974
870
  end
975
871
 
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?
872
+ # The host chi uses when hosts: names none: server.host/port/transport.
873
+ def default_host_config(data, env)
874
+ opts = { file_data: data, env: env, cli_overrides: Samagotchi::Config.cli_overrides }
875
+ host = Samagotchi::Config.resolve("server.host", **opts).to_s.strip
876
+ host = "localhost" if host.empty?
877
+ port = Samagotchi::Config.resolve("server.port", **opts).to_i
878
+ port = 8080 if port <= 0 || port > 65_535
879
+ transport = Samagotchi::Config.resolve_with_origin("server.transport", **opts).then do |value, origin|
880
+ origin == :default ? nil : value.to_sym
881
+ end
882
+ HostConfig.new(name: "default", host: host, port: port, transport: transport)
979
883
  end
980
- private_class_method :host_models_config
884
+ private_class_method :default_host_config
981
885
 
982
886
  # Two hosts declaring one id under hosts.<name>.models: a bare id goes
983
887
  # where HostRegistry#host_for_model sends it (the default host when it
984
888
  # declares the id, else the first in hosts: order); say so once.
985
889
  def warn_duplicate_host_models(hosts)
986
890
  declared = Hash.new { |h, k| h[k] = [] }
987
- hosts.each { |name, cfg| cfg[:models].each { |down, model| declared[down] << [name, model.id] } }
891
+ hosts.each { |name, cfg| cfg.models.each { |down, model| declared[down] << [name, model.id] } }
988
892
  declared.each_value do |owners|
989
893
  next if owners.size < 2
990
894
 
@@ -1005,7 +909,7 @@ module Samagotchi
1005
909
  raw = hosts_source(read_yaml(env: env, path: path), env)
1006
910
  return [] unless raw.is_a?(Hash)
1007
911
 
1008
- raw.filter_map { |name, cfg| name.to_s.strip.downcase if cfg.is_a?(Hash) && host_disabled?(cfg) }
912
+ raw.filter_map { |name, cfg| name.to_s.strip.downcase if cfg.is_a?(Hash) && HostConfig.disabled?(cfg) }
1009
913
  rescue StandardError
1010
914
  []
1011
915
  end
@@ -1026,17 +930,6 @@ module Samagotchi
1026
930
  end
1027
931
  private_class_method :hosts_source
1028
932
 
1029
- # enabled: false (or "false", any case) in a hosts entry.
1030
- def host_disabled?(cfg)
1031
- enabled = if cfg.key?("enabled")
1032
- cfg["enabled"]
1033
- else
1034
- (cfg.key?(:enabled) ? cfg[:enabled] : true)
1035
- end
1036
- enabled == false || enabled.to_s.strip.downcase == "false"
1037
- end
1038
- private_class_method :host_disabled?
1039
-
1040
933
  # Resolve the `recap:` section through the Config registry so scalar
1041
934
  # recap settings share the single precedence path (CLI > ENV > file >
1042
935
  # default). Returns false when explicitly disabled (file `recap: false`
@@ -1115,25 +1008,14 @@ module Samagotchi
1115
1008
  # include when hosts file exists with hosts: section or when workers need propagation
1116
1009
  return nil if hosts.nil? || hosts.empty?
1117
1010
 
1118
- # Serialize to JSON with string keys
1119
- simple = hosts.transform_values do |v|
1120
- # A url entry travels as its url (host/port come from it); the API
1121
- # key stays in the environment, which workers inherit.
1122
- location = v[:url] ? { "url" => v[:url] } : { "host" => v[:host], "port" => v[:port] }
1123
- location.merge("transport" => v[:transport]&.to_s, "api" => v[:api]&.to_s, "api_key_env" => v[:api_key_env],
1124
- "profile" => v[:profile], "first_token_timeout" => v[:first_token_timeout],
1125
- "vision" => v[:vision], "sampling" => v[:sampling], "thinking" => v[:thinking]&.to_s,
1126
- "remote" => v[:remote], "window_tokens" => v[:window_tokens],
1127
- "llm_context_strategy" => v[:llm_context_strategy]&.map(&:to_s),
1128
- "llm_context_apply" => v[:llm_context_apply]&.to_s,
1129
- "llm_context_budget_tokens" => v[:llm_context_budget_tokens],
1130
- "models" => host_models_config(v[:models])).compact
1131
- end
1011
+ # Every field travels (HostConfig#to_env_h); the worker parses it back.
1012
+ simple = hosts.transform_values(&:to_env_h)
1132
1013
  # Disabled hosts travel as just that, so a worker refuses "box:x"
1133
1014
  # the way its parent does instead of sending it to the default host.
1134
1015
  disabled_host_names(env: env, path: path).grep(HOST_NAME_RE).each { |name| simple[name] ||= { "enabled" => false } }
1135
1016
  JSON.generate(simple)
1136
- rescue StandardError
1017
+ rescue StandardError => e
1018
+ Log.error(:config, "hosts_json_failed", error: e.class.name, message: e.message)
1137
1019
  nil
1138
1020
  end
1139
1021
 
@@ -1197,9 +1079,9 @@ module Samagotchi
1197
1079
  end
1198
1080
 
1199
1081
  # The top-level `models:` map: per-model settings keyed by model id or
1200
- # alias (downcased), e.g. `models: {my-alias: {profile: qwen36}}`. Values are
1201
- # kept as written (profile downcased; ModelProfile.resolve validates it);
1202
- # an entry that is not a map is skipped.
1082
+ # alias (downcased), e.g. `models: {my-alias: {profile: qwen36}}`, each a
1083
+ # ModelSettings; an entry that is not a map is skipped.
1084
+ # @return [Hash{String => ModelSettings}]
1203
1085
  def model_settings(env: ENV, path: global_path(env: env))
1204
1086
  data = read_yaml(env: env, path: path)
1205
1087
  raw = data[MODELS_KEY] if data.is_a?(Hash)
@@ -1207,40 +1089,26 @@ module Samagotchi
1207
1089
 
1208
1090
  raw.each_with_object({}) do |(k, v), result|
1209
1091
  key = k.to_s.strip.downcase
1210
- next if key.empty? || !v.is_a?(Hash)
1211
-
1212
- profile = (v["profile"] || v[:profile]).to_s.strip.downcase
1213
- vision = vision_flag(v.key?("vision") ? v["vision"] : v[:vision], "models: #{key}")
1214
- result[key] = { profile: profile.empty? ? nil : profile }
1215
- result[key][:vision] = vision unless vision.nil?
1216
- sampling = sampling_map(v.key?("sampling") ? v["sampling"] : v[:sampling], "models: #{key}")
1217
- result[key][:sampling] = sampling if sampling
1218
- thinking = Thinking.level(v.key?("thinking") ? v["thinking"] : v[:thinking], "models: #{key}")
1219
- result[key][:thinking] = thinking if thinking
1220
- window = window_tokens(v.key?("window_tokens") ? v["window_tokens"] : v[:window_tokens], "models: #{key}")
1221
- result[key][:window_tokens] = window if window
1222
- layers = LLMContextStrategy.parse(v.key?(LLMContextStrategy::KEY) ? v[LLMContextStrategy::KEY] : v[:llm_context_strategy],
1223
- "models: #{key}")
1224
- result[key][:llm_context_strategy] = layers if layers
1225
- apply = LLMContextStrategy.parse_apply(v.key?(LLMContextStrategy::APPLY_KEY) ? v[LLMContextStrategy::APPLY_KEY] : v[:llm_context_apply],
1226
- "models: #{key}")
1227
- result[key][:llm_context_apply] = apply if apply
1228
- budget = LLMContextStrategy.parse_budget(v.key?(LLMContextStrategy::BUDGET_KEY) ? v[LLMContextStrategy::BUDGET_KEY] : v[:llm_context_budget_tokens],
1229
- "models: #{key}")
1230
- result[key][:llm_context_budget_tokens] = budget if budget
1092
+ settings = ModelSettings.parse(key, v)
1093
+ result[key] = settings if settings
1231
1094
  end
1232
1095
  rescue StandardError
1233
1096
  {}
1234
1097
  end
1235
1098
 
1236
1099
  # The first +names+ (model as typed, alias-resolved, bare, …) whose
1237
- # models: entry sets +field+: [key, value], or nil. VisionSupport and
1238
- # SamplingSettings look a model up the same way.
1100
+ # models: entry sets +field+ (a ModelSettings member; another name
1101
+ # raises ArgumentError): a ModelSetting (key, value), or nil.
1102
+ # VisionSupport and SamplingSettings look a model up the same way.
1103
+ # @param models [Hash{String => ModelSettings}, nil] model_settings by default
1104
+ # @return [ModelSetting, nil]
1239
1105
  def model_setting(names, field, models: nil)
1106
+ raise ArgumentError, "unknown models: field #{field.inspect}" unless ModelSettings.members.include?(field)
1107
+
1240
1108
  models ||= model_settings
1241
1109
  Array(names).map { |name| name.to_s.strip.downcase }.reject(&:empty?).uniq.each do |key|
1242
- value = models.dig(key, field)
1243
- return [key, value] unless value.nil?
1110
+ value = models[key]&.public_send(field)
1111
+ return ModelSetting.new(key: key, value: value) unless value.nil?
1244
1112
  end
1245
1113
  nil
1246
1114
  end
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "yaml"
5
5
  require "psych"
6
+ require_relative "yaml_lines"
6
7
 
7
8
  module Samagotchi
8
9
  # Sets one section.key in config.yml text, line by line, so the user's
@@ -15,7 +16,9 @@ module Samagotchi
15
16
  # section, anchors, a duplicate section), the whole file is dumped from
16
17
  # +expected+ instead.
17
18
  module ConfigTextEdit
18
- DEFAULT_INDENT = 2
19
+ extend YAMLLines
20
+
21
+ DEFAULT_INDENT = YAMLLines::DEFAULT_INDENT
19
22
 
20
23
  module_function
21
24
 
@@ -42,9 +45,7 @@ module Samagotchi
42
45
  section_data = original[section]
43
46
  has_key = section_data.is_a?(Hash) && section_data.keys.any? { |k| k.to_s.downcase == key.downcase }
44
47
 
45
- eol = text.include?("\r\n") ? "\r\n" : "\n"
46
- lines = text.split(/(?<=\n)/)
47
- lines[-1] = "#{lines[-1]}#{eol}" if lines.any? && !lines[-1].end_with?("\n")
48
+ lines, eol = lines_of(text)
48
49
  header = /\A#{Regexp.escape(section)}:[ \t]*(#.*)?\z/
49
50
  starts = lines.each_index.select { |i| lines[i].chomp.match?(header) }
50
51
  return nil if starts.length > 1
@@ -57,9 +58,8 @@ module Samagotchi
57
58
  end
58
59
 
59
60
  at = starts.first
60
- stop = at + 1
61
- stop += 1 while stop < lines.length && inside_block?(lines[stop])
62
- children = lines[(at + 1)...stop].reject { |line| line.strip.empty? || comment?(line) }
61
+ stop = block_stop(lines, at)
62
+ children = lines[(at + 1)...stop].reject { |line| blank_or_comment?(line) }
63
63
  child = children.first ? indent(children.first) : DEFAULT_INDENT
64
64
  new_line = "#{" " * child}#{key}: #{scalar(value)}#{eol}"
65
65
  found = ((at + 1)...stop).find { |i| indent(lines[i]) == child && key_of(lines[i])&.downcase == key.downcase }
@@ -68,8 +68,7 @@ module Samagotchi
68
68
  else
69
69
  return nil if has_key
70
70
 
71
- stop -= 1 while stop > at + 1 && (lines[stop - 1].strip.empty? || (comment?(lines[stop - 1]) && indent(lines[stop - 1]) < child))
72
- lines.insert(stop, new_line)
71
+ lines.insert(insert_at(lines, at, stop, child), new_line)
73
72
  end
74
73
  lines.join
75
74
  end
@@ -78,30 +77,7 @@ module Samagotchi
78
77
  line[/\A\s*(["']?)([^"':#\s][^"':#]*?)\1:(?:\s|\z)/, 2]
79
78
  end
80
79
 
81
- def inside_block?(line)
82
- return true if line.strip.empty?
83
- return false if line.start_with?("---", "...")
84
-
85
- line.start_with?(" ", "\t") || comment?(line)
86
- end
87
-
88
- def comment?(line) = line.lstrip.start_with?("#")
89
- def indent(line) = line[/\A */].length
90
-
91
80
  # JSON's quoting is valid YAML: model ids hold "/" and ":".
92
81
  def scalar(value) = JSON.generate(value.to_s)
93
-
94
- def parse(text) = YAML.safe_load(text, permitted_classes: [], aliases: false)
95
-
96
- def anchors?(text)
97
- stack = [Psych.parse_stream(text)]
98
- until stack.empty?
99
- node = stack.pop
100
- return true if node.is_a?(Psych::Nodes::Alias) || (node.respond_to?(:anchor) && node.anchor)
101
-
102
- stack.concat(Array(node.children)) if node.respond_to?(:children)
103
- end
104
- false
105
- end
106
82
  end
107
83
  end
@@ -220,13 +220,6 @@ module Samagotchi
220
220
  data ? Snapshot.from_h(data) : Snapshot.empty
221
221
  end
222
222
 
223
- # @return [Time, nil] the snapshot file's mtime: the worker's cheap "anything new?"
224
- def snapshot_mtime(name)
225
- File.mtime(snapshot_path(name))
226
- rescue SystemCallError
227
- nil
228
- end
229
-
230
223
  def write_snapshot(name, snapshot)
231
224
  FileUtils.mkdir_p(dir)
232
225
  AtomicFile.write(snapshot_path(name), JSON.generate(snapshot.to_h))
@@ -50,11 +50,11 @@ module Samagotchi
50
50
  # and vision look a model up), else hosts.<name>.window_tokens.
51
51
  # @param target [HostRegistry::ModelTarget]
52
52
  # @param names [Array<String>] HostRegistry#lookup_names
53
- # @param models [Hash, nil] ConfigFile.model_settings (specs)
53
+ # @param models [Hash{String => ModelSettings}, nil] ConfigFile.model_settings (specs)
54
54
  # @return [Resolved, nil]
55
55
  def setting(target, names:, models: nil)
56
56
  models ||= ConfigFile.model_settings
57
- _key, tokens = ConfigFile.model_setting(names, :window_tokens, models: models)
57
+ tokens = ConfigFile.model_setting(names, :window_tokens, models: models)&.value
58
58
  return Resolved.new(tokens: tokens, source: :model_setting) if positive_integer?(tokens)
59
59
 
60
60
  tokens = target&.entry&.window_tokens
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require_relative "last_turn"
4
5
  require_relative "log"
5
6
 
6
7
  module Samagotchi
@@ -85,11 +86,11 @@ module Samagotchi
85
86
  # Stop runs no turn: last_turn says so (not_continued), so a wait on
86
87
  # it ends (chi answer --option Stop).
87
88
  def announce_resolved(result, command)
88
- @engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command[:client_id])
89
+ @engine.announce(type: :continue_resolved, decision: result.decision.to_s, client_id: command.client_id)
89
90
  return if result.resume
90
91
 
91
92
  session = @engine.session
92
- session.last_turn = { "outcome" => NOT_CONTINUED, "ended_at" => Time.now.iso8601(3) } if session
93
+ session.last_turn = LastTurn.new(outcome: NOT_CONTINUED, ended_at: Time.now.iso8601(3)) if session
93
94
  end
94
95
 
95
96
  # After a command's command_ran and save: a /continue that answered the
@@ -128,7 +129,7 @@ module Samagotchi
128
129
  Log.warn(:worker, "turn_not_begun", continue: true, error: e.class.name, msg: e.message)
129
130
  return announce_offer(offer)
130
131
  end
131
- @run_turn.call(nil, continue: true, origin: { client_id: command[:client_id] }.compact,
132
+ @run_turn.call(nil, continue: true, origin: { client_id: command.client_id }.compact,
132
133
  max_iterations: @max_iterations.call(offer[:no_interrupt])) do |result, error|
133
134
  if error
134
135
  announce_offer(offer)
@@ -183,7 +184,7 @@ module Samagotchi
183
184
  # The limit the offer's turn ran out at (Engine's last_turn marker).
184
185
  def limit_of_last_turn
185
186
  last = @engine.session&.last_turn
186
- last.is_a?(Hash) && last["exhausted"] ? last["limit"] : nil
187
+ last&.exhausted ? last.limit : nil
187
188
  end
188
189
 
189
190
  # The question's answer as the /continue line a typed answer would be:
@@ -22,7 +22,6 @@ module Samagotchi
22
22
  Outcome = Data.define(:kind) do
23
23
  def stopped? = kind == :stopped
24
24
  def again? = kind == :again
25
- def hook? = kind == :hook
26
25
  end
27
26
 
28
27
  STOPPED = Outcome.new(kind: :stopped)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Samagotchi
4
+ # What a message sent to a running turn does when it arrives: wait for the
5
+ # turn's next step boundary and go in there (the default), cut the running
6
+ # generation short to go in now, or wait for the turn to end and run as a
7
+ # turn of its own (see docs/configuration.md). The field travels as the
8
+ # wire value name; a sender that names nothing gets the default.
9
+ module Delivery
10
+ NEXT_STEP = "next_step"
11
+ CUT = "cut"
12
+ QUEUE = "queue"
13
+ VALUES = [NEXT_STEP, CUT, QUEUE].freeze
14
+
15
+ # A body's delivery field as one of VALUES. Missing, empty or unknown:
16
+ # NEXT_STEP (an older client, a hand-written request).
17
+ # @param raw [Object] the wire value
18
+ # @return [String] one of VALUES
19
+ def self.parse(raw)
20
+ raw.is_a?(String) && VALUES.include?(raw) ? raw : NEXT_STEP
21
+ end
22
+
23
+ # Why a request body's delivery field can't be taken, as {error:,
24
+ # detail:} for a 400, or nil when it can. The Bridge and the web's
25
+ # POST /turn both ask this, so they refuse the same values. A missing
26
+ # or unknown string is not refused: it is the default (#parse). QUEUE
27
+ # is refused until a worker runs it (no worker reads it yet, so it
28
+ # would merge at the next step while the ack said queue).
29
+ # @param raw [Object] the wire value
30
+ # @return [Hash, nil]
31
+ def self.refusal(raw)
32
+ return nil if raw.nil?
33
+ return { error: "bad_delivery", detail: "delivery must be a string" } unless raw.is_a?(String)
34
+ return QUEUE_UNAVAILABLE if raw == QUEUE
35
+
36
+ nil
37
+ end
38
+
39
+ QUEUE_UNAVAILABLE = {
40
+ error: "delivery_unavailable",
41
+ detail: "delivery queue isn't available yet; without it the message goes in at the running turn's next step " \
42
+ "(or runs as a turn when none runs)"
43
+ }.freeze
44
+ private_constant :QUEUE_UNAVAILABLE
45
+
46
+ # Whether it is the one every sender defaulting to a step boundary uses.
47
+ def self.next_step?(value) = parse(value) == NEXT_STEP
48
+
49
+ # Whether the running turn should be cut for it (only when the message
50
+ # asks: nothing else cuts).
51
+ def self.cut?(value) = parse(value) == CUT
52
+
53
+ # Whether it waits for the turn to end and runs as a turn of its own.
54
+ def self.queue?(value) = parse(value) == QUEUE
55
+ end
56
+ end
@@ -954,7 +954,7 @@ module Samagotchi
954
954
  served, served_for = served_model_for(snapshot, target: target)
955
955
  snapshot = snapshot.merge(served_model: served, served_model_for: served_for,
956
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)
957
+ thinking: thinking_explained&.summary, prompt_notes: prompt_notes.map(&:to_h), price_host: target.entry.name)
958
958
  unless snapshot.dig(:context, :window_tokens)
959
959
  window = current_context_window(target)
960
960
  if window
@@ -1029,16 +1029,46 @@ module Samagotchi
1029
1029
  nil
1030
1030
  end
1031
1031
 
1032
- # "off (models: qwen)" for /model: the effective model's thinking level
1033
- # and where it came from, nil when none is set.
1032
+ # "off (models: qwen)", "low (session)" for /model: the effective
1033
+ # model's thinking level and where it came from, nil when none is set.
1034
1034
  def thinking_summary
1035
+ explained = thinking_explained
1036
+ explained&.source ? explained.label : nil
1037
+ end
1038
+
1039
+ # The effective model's thinking level with where it came from and the
1040
+ # session's own (Thinking::Explained), as the next turn resolves it;
1041
+ # nil when it can't be resolved.
1042
+ # @return [Thinking::Explained, nil]
1043
+ def thinking_explained
1035
1044
  target = @host_registry.resolve(@effective_model_name)
1036
- level, source = Thinking.resolve(target, names: model_lookup_names(target))
1037
- source ? "#{level} (#{source})" : nil
1045
+ Thinking.explain(target, names: model_lookup_names(target), session: thinking_override)
1038
1046
  rescue StandardError
1039
1047
  nil
1040
1048
  end
1041
1049
 
1050
+ # The session's own thinking level, nil when it has none.
1051
+ # @return [Symbol, nil]
1052
+ def thinking_override = @session&.thinking
1053
+
1054
+ # What a thinking level change costs the effective model's prompt
1055
+ # cache (Thinking.cache_cost): :full, :provider or :tail.
1056
+ # @return [Symbol]
1057
+ def thinking_cache_cost
1058
+ target = @host_registry.resolve(@effective_model_name)
1059
+ Thinking.cache_cost(target, profile: target.entry&.chat? ? nil : profile&.name)
1060
+ end
1061
+
1062
+ # Set the session's own thinking level (nil unsets it); the next turn's
1063
+ # start resolves it, never a running one (its TurnSettings are fixed).
1064
+ # The caller saves the session.
1065
+ # @raise [ArgumentError] with no session yet
1066
+ def thinking_override=(level)
1067
+ raise ArgumentError, "no session yet" unless @session
1068
+
1069
+ @session.thinking = level
1070
+ end
1071
+
1042
1072
  # The model the server serves for the current model, and the name asked
1043
1073
  # for: what the last generation of that name reported, else llama.cpp's
1044
1074
  # model_alias (/props, one short cached probe; not with probe: false),
@@ -1105,6 +1135,7 @@ module Samagotchi
1105
1135
  served_expected_by: served_expected_by(served_pair[0]),
1106
1136
  context_status: @last_context_status&.dup || saved_context_status(metrics[:context]),
1107
1137
  llm_context: llm_context_summary,
1138
+ thinking: thinking_explained&.summary,
1108
1139
  recap_enabled: !@recap.nil?,
1109
1140
  recap_min_user_turns: @recap&.min_user_turns,
1110
1141
  recap_inactivity_seconds: @recap&.inactivity&.to_i
@@ -1242,22 +1273,22 @@ module Samagotchi
1242
1273
  elsif client_id == ClientId::REMINDER then "reminder"
1243
1274
  else "client"
1244
1275
  end
1245
- session.last_turn = { "outcome" => outcome, "ended_at" => Time.now.iso8601(3),
1246
- "seconds" => seconds.round(1), "origin" => source }
1247
- session.last_turn.merge!("exhausted" => true, "limit" => limit) if limit
1248
- session.last_turn.merge!(stop_facts(event))
1276
+ session.last_turn = LastTurn.new(outcome: outcome, ended_at: Time.now.iso8601(3), seconds: seconds.round(1),
1277
+ origin: source, exhausted: limit ? true : nil, limit: limit,
1278
+ **stop_facts(event))
1249
1279
  end
1250
1280
 
1251
- # Why a turn stopped, from its end event, for a parent agent's wait
1252
- # (ReplyWait): a provider error's kind and whether a retry may help, a
1253
- # cancel's reason and the hook that stopped it. Only what is known.
1281
+ # Why a turn stopped, from its end event (LastTurn::STOP_FACTS), for a
1282
+ # parent agent's wait (ReplyWait): a provider error's kind and whether
1283
+ # a retry may help, a cancel's reason and the hook that stopped it.
1284
+ # Only what is known.
1254
1285
  def stop_facts(event)
1255
1286
  case event&.dig(:type)
1256
1287
  # kept_steps: the turn's work stayed (a parent must not send the task again).
1257
1288
  when :turn_failed
1258
- { "error_kind" => event[:error_kind]&.to_s, "retryable" => event[:retryable], "kept_steps" => event[:kept_steps] }.compact
1289
+ { error_kind: event[:error_kind]&.to_s, retryable: event[:retryable], kept_steps: event[:kept_steps] }
1259
1290
  when :turn_canceled
1260
- { "cancel_reason" => event[:cancellation_reason]&.to_s, "stopped_by" => event[:cancelled_by]&.to_s }.compact
1291
+ { cancel_reason: event[:cancellation_reason]&.to_s, stopped_by: event[:cancelled_by]&.to_s }
1261
1292
  else {}
1262
1293
  end
1263
1294
  end
@@ -2237,9 +2268,9 @@ module Samagotchi
2237
2268
  hook_notify(text, level, "thinking")
2238
2269
  end
2239
2270
 
2240
- # +target+'s thinking level (Thinking.resolve).
2271
+ # +target+'s thinking level (Thinking.resolve), the session's own first.
2241
2272
  def thinking_level(target)
2242
- Thinking.resolve(target, names: model_lookup_names(target)).first
2273
+ Thinking.resolve(target, names: model_lookup_names(target), session: thinking_override).first
2243
2274
  rescue StandardError
2244
2275
  Thinking::DEFAULT
2245
2276
  end
@@ -2470,7 +2501,7 @@ module Samagotchi
2470
2501
  model_key: -> { @model_key },
2471
2502
  state_dir: -> { session_state_dir },
2472
2503
  scratch: -> { @scratch },
2473
- llm_context: -> { @session&.llm_context },
2504
+ setup: -> { SessionSetup.of(@session) },
2474
2505
  frontend: -> { interface }
2475
2506
  )
2476
2507
  end