samagotchi 0.51.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 (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +46 -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 +23 -8
  7. data/docs/configuration.md +59 -19
  8. data/docs/memory.md +11 -0
  9. data/docs/plugins.md +2 -1
  10. data/docs/sessions.md +5 -1
  11. data/docs/testing.md +6 -1
  12. data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
  13. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  14. data/lib/samagotchi/bridge.rb +10 -13
  15. data/lib/samagotchi/broadcast/recipients.rb +1 -2
  16. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  17. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  18. data/lib/samagotchi/children_status.rb +1 -1
  19. data/lib/samagotchi/commands/registry.rb +2 -1
  20. data/lib/samagotchi/config.rb +50 -182
  21. data/lib/samagotchi/config_text_edit.rb +8 -32
  22. data/lib/samagotchi/context_sources.rb +0 -7
  23. data/lib/samagotchi/context_window.rb +2 -2
  24. data/lib/samagotchi/continue_offer.rb +5 -4
  25. data/lib/samagotchi/cut_policy.rb +0 -1
  26. data/lib/samagotchi/delivery.rb +23 -0
  27. data/lib/samagotchi/engine.rb +48 -17
  28. data/lib/samagotchi/engine_peers.rb +1 -0
  29. data/lib/samagotchi/host_config.rb +183 -0
  30. data/lib/samagotchi/host_registry.rb +5 -10
  31. data/lib/samagotchi/last_turn.rb +48 -0
  32. data/lib/samagotchi/live_versions.rb +0 -6
  33. data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
  34. data/lib/samagotchi/llm_context_override.rb +3 -22
  35. data/lib/samagotchi/llm_context_strategy.rb +50 -16
  36. data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
  37. data/lib/samagotchi/model_profile.rb +4 -4
  38. data/lib/samagotchi/model_settings.rb +46 -0
  39. data/lib/samagotchi/plugin/context.rb +3 -3
  40. data/lib/samagotchi/plugin/sessions.rb +2 -2
  41. data/lib/samagotchi/queued_command.rb +35 -0
  42. data/lib/samagotchi/reminder_store.rb +0 -27
  43. data/lib/samagotchi/reply_wait.rb +11 -13
  44. data/lib/samagotchi/sampling_settings.rb +4 -4
  45. data/lib/samagotchi/self_report.rb +8 -5
  46. data/lib/samagotchi/send_command.rb +37 -19
  47. data/lib/samagotchi/session.rb +45 -24
  48. data/lib/samagotchi/session_commands.rb +36 -7
  49. data/lib/samagotchi/session_inbox.rb +18 -9
  50. data/lib/samagotchi/session_manager.rb +8 -7
  51. data/lib/samagotchi/session_setup.rb +30 -0
  52. data/lib/samagotchi/steer_cut.rb +9 -2
  53. data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
  54. data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
  55. data/lib/samagotchi/terminal_ui/formatting.rb +21 -3
  56. data/lib/samagotchi/terminal_ui.rb +8 -3
  57. data/lib/samagotchi/thinking.rb +67 -7
  58. data/lib/samagotchi/thinking_command.rb +72 -0
  59. data/lib/samagotchi/tools/builtins.rb +7 -2
  60. data/lib/samagotchi/tools/peers.rb +3 -2
  61. data/lib/samagotchi/tools/task_runtime.rb +1 -1
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/vision_support.rb +4 -3
  64. data/lib/samagotchi/web/app.rb +54 -6
  65. data/lib/samagotchi/web/public/app.js +133 -20
  66. data/lib/samagotchi/web/public/data.js +8 -4
  67. data/lib/samagotchi/web/public/format.js +8 -4
  68. data/lib/samagotchi/web/public/index.html +8 -5
  69. data/lib/samagotchi/web/public/kept_commands.js +52 -0
  70. data/lib/samagotchi/web/public/model_pick.js +3 -1
  71. data/lib/samagotchi/web/public/thinking_chip.js +169 -0
  72. data/lib/samagotchi/web/public/timing.js +15 -0
  73. data/lib/samagotchi/web/public/turn_events.js +23 -0
  74. data/lib/samagotchi/web/public/turn_view.js +328 -310
  75. data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
  76. data/lib/samagotchi/web/session_summary.rb +1 -1
  77. data/lib/samagotchi/worker.rb +43 -42
  78. data/lib/samagotchi/worker_inbound.rb +5 -5
  79. data/lib/samagotchi/worker_wakes.rb +0 -2
  80. data/lib/samagotchi/yaml_lines.rb +72 -0
  81. metadata +11 -1
@@ -58,7 +58,7 @@ Only the nested form is read. An env name used as a top-level key (`SAMAGOTCHI_D
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
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
- - `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).
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: the session's own level (`/thinking LEVEL`, saved in the session; `chi --thinking` on a session run, `chi send --new --thinking`) > `chi web --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.
64
64
 
@@ -1,11 +1,11 @@
1
1
  ---
2
2
  name: samagotchi-system
3
- version: 0.51.0
3
+ version: 0.52.0
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:337ad1b4cc9308dc5abb47b13f51a76df4691ba80fe41648f391687b5b929b65
9
+ config_modification_protocol.md: sha256:a7a6352c03979db69d98955bee8f36eb468a6cf68904d418b7f3ddb788c83e7b
10
10
  memory_guide.md: sha256:15cd3b24eda853cc458371c6c75809a2d4b5352311d075aaa4384926b5221c27
11
11
  delegated.md: sha256:aedaa1e756a244904c5560fff0424e06fb468bbdcb0358bccefe321d67cd2614
@@ -95,7 +95,7 @@ module Samagotchi
95
95
  return "running" if Tools::Delegate.running?(row)
96
96
  return "stopped" if session.status == Session::STATUS_STOPPED
97
97
 
98
- outcome = session.last_turn.is_a?(Hash) ? session.last_turn["outcome"] : nil
98
+ outcome = session.last_turn&.outcome
99
99
  return "failed" if session.status == Session::STATUS_ERROR || outcome == "failed"
100
100
  return "done" if outcome == "completed" && reply
101
101
 
@@ -21,7 +21,8 @@ module Samagotchi
21
21
  # turn runs (MID_TURN): a symbol, or a lambda line (stripped) → one
22
22
  # @!attribute local [Boolean] the UI runs it; completion and help only
23
23
  # @!attribute uis [Array<Symbol>, nil] a local entry's UIs (:repl,
24
- # :attached) for completion; nil means every UI
24
+ # :attached, :web) for completion and /help; nil means every UI
25
+ # (for a local entry, every terminal)
25
26
  # @!attribute match [#call] line (stripped) → whether it is this command
26
27
  # @!attribute handler [Proc, nil] runs it; SessionCommands instance_execs
27
28
  # a built-in's with the line, and calls a bundle's with the text
@@ -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)
@@ -20,6 +20,29 @@ module Samagotchi
20
20
  raw.is_a?(String) && VALUES.include?(raw) ? raw : NEXT_STEP
21
21
  end
22
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
+
23
46
  # Whether it is the one every sender defaulting to a step boundary uses.
24
47
  def self.next_step?(value) = parse(value) == NEXT_STEP
25
48