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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -1
- data/README.md +1 -1
- data/bin/chi +11 -3
- data/docs/architecture.md +1 -1
- data/docs/cli.md +23 -8
- data/docs/configuration.md +59 -19
- data/docs/memory.md +11 -0
- data/docs/plugins.md +2 -1
- data/docs/sessions.md +5 -1
- data/docs/testing.md +6 -1
- data/lib/samagotchi/bootstrap/config_writer.rb +6 -37
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +10 -13
- data/lib/samagotchi/broadcast/recipients.rb +1 -2
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
- data/lib/samagotchi/bundles/system/manifest.yml +2 -2
- data/lib/samagotchi/children_status.rb +1 -1
- data/lib/samagotchi/commands/registry.rb +2 -1
- data/lib/samagotchi/config.rb +50 -182
- data/lib/samagotchi/config_text_edit.rb +8 -32
- data/lib/samagotchi/context_sources.rb +0 -7
- data/lib/samagotchi/context_window.rb +2 -2
- data/lib/samagotchi/continue_offer.rb +5 -4
- data/lib/samagotchi/cut_policy.rb +0 -1
- data/lib/samagotchi/delivery.rb +23 -0
- data/lib/samagotchi/engine.rb +48 -17
- data/lib/samagotchi/engine_peers.rb +1 -0
- data/lib/samagotchi/host_config.rb +183 -0
- data/lib/samagotchi/host_registry.rb +5 -10
- data/lib/samagotchi/last_turn.rb +48 -0
- data/lib/samagotchi/live_versions.rb +0 -6
- data/lib/samagotchi/llm/native_tool_normalizer.rb +0 -5
- data/lib/samagotchi/llm_context_override.rb +3 -22
- data/lib/samagotchi/llm_context_strategy.rb +50 -16
- data/lib/samagotchi/memory_bundle/placeholder.rb +0 -5
- data/lib/samagotchi/model_profile.rb +4 -4
- data/lib/samagotchi/model_settings.rb +46 -0
- data/lib/samagotchi/plugin/context.rb +3 -3
- data/lib/samagotchi/plugin/sessions.rb +2 -2
- data/lib/samagotchi/queued_command.rb +35 -0
- data/lib/samagotchi/reminder_store.rb +0 -27
- data/lib/samagotchi/reply_wait.rb +11 -13
- data/lib/samagotchi/sampling_settings.rb +4 -4
- data/lib/samagotchi/self_report.rb +8 -5
- data/lib/samagotchi/send_command.rb +37 -19
- data/lib/samagotchi/session.rb +45 -24
- data/lib/samagotchi/session_commands.rb +36 -7
- data/lib/samagotchi/session_inbox.rb +18 -9
- data/lib/samagotchi/session_manager.rb +8 -7
- data/lib/samagotchi/session_setup.rb +30 -0
- data/lib/samagotchi/steer_cut.rb +9 -2
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +10 -4
- data/lib/samagotchi/terminal_ui/attached_loop.rb +1 -1
- data/lib/samagotchi/terminal_ui/formatting.rb +21 -3
- data/lib/samagotchi/terminal_ui.rb +8 -3
- data/lib/samagotchi/thinking.rb +67 -7
- data/lib/samagotchi/thinking_command.rb +72 -0
- data/lib/samagotchi/tools/builtins.rb +7 -2
- data/lib/samagotchi/tools/peers.rb +3 -2
- data/lib/samagotchi/tools/task_runtime.rb +1 -1
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +4 -3
- data/lib/samagotchi/web/app.rb +54 -6
- data/lib/samagotchi/web/public/app.js +133 -20
- data/lib/samagotchi/web/public/data.js +8 -4
- data/lib/samagotchi/web/public/format.js +8 -4
- data/lib/samagotchi/web/public/index.html +8 -5
- data/lib/samagotchi/web/public/kept_commands.js +52 -0
- data/lib/samagotchi/web/public/model_pick.js +3 -1
- data/lib/samagotchi/web/public/thinking_chip.js +169 -0
- data/lib/samagotchi/web/public/timing.js +15 -0
- data/lib/samagotchi/web/public/turn_events.js +23 -0
- data/lib/samagotchi/web/public/turn_view.js +328 -310
- data/lib/samagotchi/web/public/vendor/lit-html.js +8 -0
- data/lib/samagotchi/web/session_summary.rb +1 -1
- data/lib/samagotchi/worker.rb +43 -42
- data/lib/samagotchi/worker_inbound.rb +5 -5
- data/lib/samagotchi/worker_wakes.rb +0 -2
- data/lib/samagotchi/yaml_lines.rb +72 -0
- 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:
|
|
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.
|
|
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:
|
|
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
|
|
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
|
data/lib/samagotchi/config.rb
CHANGED
|
@@ -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
|
|
174
|
-
# instead of the window; unset or
|
|
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: :
|
|
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
|
-
|
|
852
|
-
|
|
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
|
-
#
|
|
977
|
-
def
|
|
978
|
-
|
|
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 :
|
|
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
|
|
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) &&
|
|
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
|
-
#
|
|
1119
|
-
simple = hosts.transform_values
|
|
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}}
|
|
1201
|
-
#
|
|
1202
|
-
#
|
|
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
|
-
|
|
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
|
|
1238
|
-
#
|
|
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
|
|
1243
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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:
|
data/lib/samagotchi/delivery.rb
CHANGED
|
@@ -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
|
|