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