samagotchi 0.66.0 → 0.68.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 +80 -1
- data/docs/architecture.md +55 -30
- data/docs/cli.md +3 -1
- data/docs/configuration.md +50 -9
- data/docs/context.md +35 -1
- data/docs/desktop.md +90 -22
- data/docs/guardrails.md +8 -5
- data/docs/hooks.md +2 -2
- data/docs/internals/anthropic.md +156 -0
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/bundles.md +2 -0
- data/docs/internals/engine.md +129 -38
- data/docs/internals/gemma4-contract.md +1 -1
- data/docs/internals/llm-context-bench.md +5 -4
- data/docs/internals/prompt-caching.md +26 -11
- data/docs/plugins.md +126 -23
- data/docs/sessions.md +6 -6
- data/docs/sub-agent.md +4 -1
- data/docs/testing.md +18 -4
- data/lib/samagotchi/bootstrap/probe.rb +4 -3
- data/lib/samagotchi/bootstrap_command.rb +13 -3
- data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
- data/lib/samagotchi/bridge.rb +6 -3
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
- data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
- data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
- data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
- data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
- data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
- data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/bundles/system/self_map.md +1 -1
- data/lib/samagotchi/config.rb +15 -7
- data/lib/samagotchi/context_absorber.rb +66 -13
- data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
- data/lib/samagotchi/desktop/macos/App.swift +3 -3
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
- data/lib/samagotchi/desktop/macos/Images.swift +2 -2
- data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
- data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
- data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
- data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
- data/lib/samagotchi/desktop/macos.rb +84 -13
- data/lib/samagotchi/desktop_command.rb +14 -3
- data/lib/samagotchi/effective_model.rb +283 -0
- data/lib/samagotchi/engine.rb +307 -549
- data/lib/samagotchi/engine_peers.rb +3 -0
- data/lib/samagotchi/guardrail_wiring.rb +1 -1
- data/lib/samagotchi/guardrails/approval.rb +1 -1
- data/lib/samagotchi/guardrails/approvals.rb +1 -1
- data/lib/samagotchi/guardrails/rules.rb +1 -1
- data/lib/samagotchi/guardrails/targets.rb +1 -1
- data/lib/samagotchi/hook_routes.rb +151 -0
- data/lib/samagotchi/hooks/registry.rb +2 -2
- data/lib/samagotchi/host_keys.rb +147 -0
- data/lib/samagotchi/level_setting_command.rb +99 -0
- data/lib/samagotchi/llm/adapter_common.rb +138 -0
- data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
- data/lib/samagotchi/llm/api_key.rb +23 -7
- data/lib/samagotchi/llm/chat_loop.rb +10 -6
- data/lib/samagotchi/llm/openai_chat.rb +9 -92
- data/lib/samagotchi/llm/refusal_memo.rb +29 -0
- data/lib/samagotchi/llm_context_override.rb +1 -0
- data/lib/samagotchi/llm_context_strategy.rb +5 -10
- data/lib/samagotchi/peer_sources.rb +58 -0
- data/lib/samagotchi/plugin/api.rb +64 -155
- data/lib/samagotchi/plugin/loader.rb +4 -2
- data/lib/samagotchi/plugin/peer.rb +394 -0
- data/lib/samagotchi/plugin/tool_spec.rb +166 -0
- data/lib/samagotchi/plugin_tasks.rb +2 -2
- data/lib/samagotchi/reasoning_history_command.rb +29 -90
- data/lib/samagotchi/row_refs.rb +1 -1
- data/lib/samagotchi/self_report.rb +5 -3
- data/lib/samagotchi/send_command.rb +21 -11
- data/lib/samagotchi/session.rb +22 -8
- data/lib/samagotchi/session_continue.rb +58 -15
- data/lib/samagotchi/session_inbox.rb +0 -13
- data/lib/samagotchi/session_setting.rb +86 -0
- data/lib/samagotchi/thinking_command.rb +24 -76
- data/lib/samagotchi/thinking_notices.rb +169 -0
- data/lib/samagotchi/tool_call_parser.rb +1 -1
- data/lib/samagotchi/toolkit_binding.rb +138 -0
- data/lib/samagotchi/tools/context_read.rb +14 -0
- data/lib/samagotchi/tools/execute.rb +12 -12
- data/lib/samagotchi/tools/registry.rb +1 -1
- data/lib/samagotchi/tools/task_create.rb +6 -6
- data/lib/samagotchi/tools/task_get.rb +13 -13
- data/lib/samagotchi/tools/task_list.rb +6 -6
- data/lib/samagotchi/tools/task_record.rb +52 -0
- data/lib/samagotchi/tools/task_runtime.rb +55 -91
- data/lib/samagotchi/tools/task_stop.rb +6 -6
- data/lib/samagotchi/tools/task_wait.rb +12 -13
- data/lib/samagotchi/tools/tool_index.rb +454 -0
- data/lib/samagotchi/update_command.rb +9 -6
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +71 -93
- data/lib/samagotchi/web/capabilities.rb +1 -1
- data/lib/samagotchi/web/public/activity.js +2 -2
- data/lib/samagotchi/web/public/annotate_actions.js +1 -1
- data/lib/samagotchi/web/public/app.js +103 -539
- data/lib/samagotchi/web/public/bubbles.js +291 -0
- data/lib/samagotchi/web/public/cards_place.js +184 -0
- data/lib/samagotchi/web/public/chains.js +1 -1
- data/lib/samagotchi/web/public/chip_popover.js +111 -0
- data/lib/samagotchi/web/public/command_list.js +12 -10
- data/lib/samagotchi/web/public/commands.js +2 -2
- data/lib/samagotchi/web/public/composer_size.js +2 -2
- data/lib/samagotchi/web/public/copy.js +1 -1
- data/lib/samagotchi/web/public/ctx.js +1 -1
- data/lib/samagotchi/web/public/delivery_line.js +4 -4
- data/lib/samagotchi/web/public/families_view.js +1 -1
- data/lib/samagotchi/web/public/format.js +11 -7
- data/lib/samagotchi/web/public/images.js +25 -2
- data/lib/samagotchi/web/public/index.html +15 -7
- data/lib/samagotchi/web/public/info_bar.js +3 -3
- data/lib/samagotchi/web/public/info_bar_model.js +110 -0
- data/lib/samagotchi/web/public/init_row.js +21 -10
- data/lib/samagotchi/web/public/level_chip.js +29 -80
- data/lib/samagotchi/web/public/lists_view.js +64 -9
- data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
- data/lib/samagotchi/web/public/model_picker.js +26 -22
- data/lib/samagotchi/web/public/notify.js +6 -4
- data/lib/samagotchi/web/public/question_card.js +1 -1
- data/lib/samagotchi/web/public/ref_scan.js +2 -8
- data/lib/samagotchi/web/public/refs.js +13 -0
- data/lib/samagotchi/web/public/session_state.js +14 -0
- data/lib/samagotchi/web/public/session_title.js +2 -3
- data/lib/samagotchi/web/public/sessions_list.js +0 -8
- data/lib/samagotchi/web/public/stage_frame.js +179 -0
- data/lib/samagotchi/web/public/stage_model.js +35 -2
- data/lib/samagotchi/web/public/stage_slots.js +130 -0
- data/lib/samagotchi/web/public/stage_view.js +56 -335
- data/lib/samagotchi/web/public/stage_watch.js +65 -0
- data/lib/samagotchi/web/public/strip.js +3 -3
- data/lib/samagotchi/web/public/task_stop.js +3 -2
- data/lib/samagotchi/web/public/turn_events.js +1 -1
- data/lib/samagotchi/web/public/turn_html.js +2 -2
- data/lib/samagotchi/web/public/turn_model.js +2 -2
- data/lib/samagotchi/web/public/turn_view.js +1 -1
- data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
- data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
- data/lib/samagotchi/web/server.rb +0 -4
- data/lib/samagotchi/worker.rb +22 -24
- data/lib/samagotchi/worker_inbound.rb +36 -4
- data/lib/samagotchi/worker_wakes.rb +67 -7
- metadata +27 -1
|
@@ -11,6 +11,7 @@ require_relative "../version"
|
|
|
11
11
|
require_relative "../self_report"
|
|
12
12
|
require_relative "../installed_gem"
|
|
13
13
|
require_relative "../config"
|
|
14
|
+
require_relative "../host_keys"
|
|
14
15
|
require_relative "../redacted_inspect"
|
|
15
16
|
|
|
16
17
|
module Samagotchi
|
|
@@ -48,6 +49,9 @@ module Samagotchi
|
|
|
48
49
|
# top of it (MacOS.broadcast_timeout).
|
|
49
50
|
BROADCAST_TRIAGE_DEADLINE = 20
|
|
50
51
|
BROADCAST_MARGIN = 10
|
|
52
|
+
# The agent terminals the helper pastes into (AgentTerminal.swift):
|
|
53
|
+
# one launch-file section each, in this order.
|
|
54
|
+
TERMINALS = %w[kitty tmux].freeze
|
|
51
55
|
|
|
52
56
|
# Runs a command without a shell; [output, success].
|
|
53
57
|
class Runner
|
|
@@ -69,10 +73,36 @@ module Samagotchi
|
|
|
69
73
|
{ "listen_on" => listen_on, "binary" => get.call("kitty.binary").to_s, "agents" => agents }
|
|
70
74
|
end
|
|
71
75
|
|
|
76
|
+
# The tmux: settings, or nil while tmux.enabled is off. The binary is
|
|
77
|
+
# tmux.binary, else the tmux on +env+'s PATH ("" when there is none:
|
|
78
|
+
# see #warnings); the socket dir is where tmux puts `-L` sockets
|
|
79
|
+
# ($TMUX_TMPDIR when it is a directory, else /tmp, then tmux-<uid>).
|
|
80
|
+
# Both are frozen at bake time, like the rest of the launch file.
|
|
81
|
+
def self.tmux_settings(get = Config.method(:get), env: ENV)
|
|
82
|
+
return nil unless get.call("tmux.enabled")
|
|
83
|
+
|
|
84
|
+
binary = get.call("tmux.binary").to_s.strip
|
|
85
|
+
binary = path_tmux(env) if binary.empty?
|
|
86
|
+
tmpdir = env["TMUX_TMPDIR"].to_s
|
|
87
|
+
tmpdir = "/tmp" if tmpdir.empty? || !File.directory?(tmpdir)
|
|
88
|
+
agents = get.call("tmux.agents").to_s.split("|").map(&:strip).reject(&:empty?)
|
|
89
|
+
{ "binary" => binary, "socket_dir" => File.join(tmpdir, "tmux-#{Process.uid}"), "agents" => agents }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.path_tmux(env)
|
|
93
|
+
env["PATH"].to_s.split(File::PATH_SEPARATOR).reject(&:empty?).map { File.join(_1, "tmux") }
|
|
94
|
+
.find { File.file?(_1) && File.executable?(_1) }.to_s
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Each switched-on terminal's settings by name ("kitty" => …).
|
|
98
|
+
def self.terminal_settings(get = Config.method(:get), env: ENV)
|
|
99
|
+
{ "kitty" => kitty_settings(get), "tmux" => tmux_settings(get, env: env) }.compact
|
|
100
|
+
end
|
|
101
|
+
|
|
72
102
|
# The helper as chi desktop and chi update make it: the settings it
|
|
73
103
|
# bakes into the launch file read from the config.
|
|
74
104
|
def self.from_config(register: true)
|
|
75
|
-
new(register: register,
|
|
105
|
+
new(register: register, terminals: terminal_settings, broadcast_timeout: broadcast_timeout)
|
|
76
106
|
end
|
|
77
107
|
|
|
78
108
|
# Seconds the helper gives `chi broadcast` before it stops it: the
|
|
@@ -86,7 +116,7 @@ module Samagotchi
|
|
|
86
116
|
|
|
87
117
|
def initialize(app_dir: nil, support_dir: nil, env: ENV, runner: Runner.new,
|
|
88
118
|
source_dir: SelfReport::SOURCE_DIR, ruby: RbConfig.ruby, version: VERSION,
|
|
89
|
-
arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil,
|
|
119
|
+
arch: nil, sources_dir: SOURCES_DIR, register: true, chi_path: nil, terminals: {},
|
|
90
120
|
broadcast_timeout: nil)
|
|
91
121
|
home = env["HOME"] || Dir.home
|
|
92
122
|
@app_dir = app_dir || File.join(home, "Applications")
|
|
@@ -100,7 +130,7 @@ module Samagotchi
|
|
|
100
130
|
@arch = arch
|
|
101
131
|
@sources_dir = sources_dir
|
|
102
132
|
@register = register
|
|
103
|
-
@
|
|
133
|
+
@terminals = terminals
|
|
104
134
|
@broadcast_timeout = broadcast_timeout
|
|
105
135
|
end
|
|
106
136
|
|
|
@@ -114,13 +144,13 @@ module Samagotchi
|
|
|
114
144
|
# What the helper runs: absolute ruby + chi (no shims, which need the
|
|
115
145
|
# shell's PATH), and the allowlisted env. chi is an installed gem's
|
|
116
146
|
# RubyGems wrapper (it activates the gem and outlives upgrades and
|
|
117
|
-
# `gem cleanup`), else this checkout's bin/chi.
|
|
118
|
-
#
|
|
147
|
+
# `gem cleanup`), else this checkout's bin/chi. A section per terminal
|
|
148
|
+
# switched on (TERMINALS order); the broadcast timeout when given.
|
|
119
149
|
def launch_config
|
|
120
150
|
env = { "LANG" => LANG }
|
|
121
151
|
ENV_ALLOWLIST.each { |name| env[name] = @env[name] unless @env[name].to_s.empty? }
|
|
122
152
|
config = { "version" => @version, "argv" => [@ruby, @chi_path], "env" => env, "sources_sha" => sources_sha }
|
|
123
|
-
config[
|
|
153
|
+
TERMINALS.each { |name| config[name] = @terminals[name] if @terminals[name] }
|
|
124
154
|
config["broadcast_timeout"] = @broadcast_timeout if @broadcast_timeout
|
|
125
155
|
config
|
|
126
156
|
end
|
|
@@ -145,11 +175,12 @@ module Samagotchi
|
|
|
145
175
|
end
|
|
146
176
|
|
|
147
177
|
# The launch file names another chi version or path than this one, or
|
|
148
|
-
# other
|
|
149
|
-
# that without a rebuild.
|
|
178
|
+
# other terminal settings or broadcast timeout: refresh_launch_file
|
|
179
|
+
# fixes that without a rebuild.
|
|
150
180
|
def launch_outdated?
|
|
151
181
|
launch = read_launch
|
|
152
|
-
launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] ||
|
|
182
|
+
launch["version"] != @version || Array(launch["argv"]) != launch_config["argv"] ||
|
|
183
|
+
TERMINALS.map { launch[_1] } != TERMINALS.map { @terminals[_1] } ||
|
|
153
184
|
launch["broadcast_timeout"] != @broadcast_timeout
|
|
154
185
|
end
|
|
155
186
|
|
|
@@ -163,10 +194,41 @@ module Samagotchi
|
|
|
163
194
|
end
|
|
164
195
|
|
|
165
196
|
def warnings
|
|
166
|
-
|
|
197
|
+
worktree = if File.file?(File.join(@source_dir, ".git"))
|
|
198
|
+
["chi runs from a linked git worktree (#{@source_dir}); the helper stops working once it is removed. " \
|
|
199
|
+
"Install from the main checkout or a gem install."]
|
|
200
|
+
end
|
|
201
|
+
Array(worktree) + terminal_warnings + host_key_warnings
|
|
202
|
+
end
|
|
167
203
|
|
|
168
|
-
|
|
169
|
-
|
|
204
|
+
# What a terminal section can't do as baked (chi update shows these
|
|
205
|
+
# too: they need no Keychain lookup).
|
|
206
|
+
def terminal_warnings
|
|
207
|
+
tmux = @terminals["tmux"]
|
|
208
|
+
return [] unless tmux && tmux["binary"].to_s.empty?
|
|
209
|
+
|
|
210
|
+
["tmux.enabled but no tmux found on PATH: set tmux.binary"]
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Each configured host key (api_key_env:) and whether the helper's chi
|
|
214
|
+
# gets it: the helper's bare env has no keys, so only the Keychain's
|
|
215
|
+
# count (HostKeys). :keychain, :shell_only (set in this shell, not in
|
|
216
|
+
# the Keychain) or :unset. Hosts come from the config the helper's chi
|
|
217
|
+
# reads: the launch file's XDG_CONFIG_HOME when baked; disabled hosts
|
|
218
|
+
# are left out. @return [Array<Hash>] name:, hosts:, state:
|
|
219
|
+
def host_keys
|
|
220
|
+
baked = read_launch["env"]
|
|
221
|
+
dir = baked["XDG_CONFIG_HOME"] if baked.is_a?(Hash)
|
|
222
|
+
# Spelled out: Paths.config_home's ~ is this process's HOME, not @env's.
|
|
223
|
+
config_env = { "XDG_CONFIG_HOME" => dir.to_s.empty? ? File.join(@env["HOME"] || Dir.home, ".config") : dir }
|
|
224
|
+
hosts = ConfigFile.hosts_config(env: config_env).values.select(&:api_key_env).group_by(&:api_key_env)
|
|
225
|
+
hosts.map do |name, entries|
|
|
226
|
+
state = if HostKeys.from_keychain(name, env: @env) then :keychain
|
|
227
|
+
elsif !@env[name].to_s.strip.empty? then :shell_only
|
|
228
|
+
else :unset
|
|
229
|
+
end
|
|
230
|
+
{ name: name, hosts: entries.map(&:name), state: state }
|
|
231
|
+
end
|
|
170
232
|
end
|
|
171
233
|
|
|
172
234
|
def info_plist
|
|
@@ -248,7 +310,8 @@ module Samagotchi
|
|
|
248
310
|
stale: stale?,
|
|
249
311
|
baked_dirs: env.select { |name, _| name.start_with?("XDG_") },
|
|
250
312
|
service: dump.include?(BUNDLE_ID),
|
|
251
|
-
running: running
|
|
313
|
+
running: running,
|
|
314
|
+
host_keys: host_keys
|
|
252
315
|
)
|
|
253
316
|
end
|
|
254
317
|
|
|
@@ -311,6 +374,14 @@ module Samagotchi
|
|
|
311
374
|
@arch ||= RbConfig::CONFIG["host_cpu"] == "arm64" ? "arm64" : `uname -m`.strip
|
|
312
375
|
end
|
|
313
376
|
|
|
377
|
+
def host_key_warnings
|
|
378
|
+
host_keys.reject { |key| key[:state] == :keychain }.map do |key|
|
|
379
|
+
where = key[:state] == :shell_only ? "is set in this shell only" : "is unset"
|
|
380
|
+
"#{key[:name]} #{where}: the helper's new sessions and model list won't reach host " \
|
|
381
|
+
"#{key[:hosts].join(", ")} until it is in the Keychain (#{HostKeys.store_command(key[:name])})"
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
314
385
|
def read_launch
|
|
315
386
|
launch = JSON.parse(File.read(launch_path))
|
|
316
387
|
launch.is_a?(Hash) ? launch : {}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "cli/command"
|
|
4
4
|
require_relative "cli/flags"
|
|
5
5
|
require_relative "desktop"
|
|
6
|
+
require_relative "host_keys"
|
|
6
7
|
|
|
7
8
|
module Samagotchi
|
|
8
9
|
# `chi desktop install|upgrade|uninstall|status`: the native helper that
|
|
@@ -121,12 +122,22 @@ module Samagotchi
|
|
|
121
122
|
dirs = s[:baked_dirs].empty? ? "(defaults)" : s[:baked_dirs].map { |name, value| "#{name}=#{value}" }.join(" ")
|
|
122
123
|
service = s[:service] ? "registered" : "not registered (try chi desktop upgrade, or log out and back in)"
|
|
123
124
|
running = s[:running] ? "running" : "not running (open -g \"#{s[:app_path]}\")"
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
rows = [["Chi Helper", version], ["app", s[:app_path]], ["runs chi", launch], ["state dirs", dirs],
|
|
126
|
+
["service", service], ["hotkey", hotkey(s[:hotkey])], ["process", running], ["at login", login(s[:login])]]
|
|
127
|
+
rows << ["host keys", host_keys(s[:host_keys])] unless Array(s[:host_keys]).empty?
|
|
128
|
+
@stdout.puts(format_rows(rows))
|
|
127
129
|
0
|
|
128
130
|
end
|
|
129
131
|
|
|
132
|
+
# `VAR keychain · VAR this shell only (security …)`: what the helper's chi gets.
|
|
133
|
+
def host_keys(keys)
|
|
134
|
+
keys.map do |key|
|
|
135
|
+
next "#{key[:name]} keychain" if key[:state] == :keychain
|
|
136
|
+
|
|
137
|
+
"#{key[:name]} #{key[:state] == :shell_only ? "this shell only" : "unset"} (#{HostKeys.store_command(key[:name])})"
|
|
138
|
+
end.join(" · ")
|
|
139
|
+
end
|
|
140
|
+
|
|
130
141
|
def hotkey(state)
|
|
131
142
|
return "unknown (the app writes it when it starts)" unless state
|
|
132
143
|
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "host_registry"
|
|
4
|
+
require_relative "log"
|
|
5
|
+
require_relative "context_window"
|
|
6
|
+
require_relative "llm_context_strategy"
|
|
7
|
+
require_relative "model_profile"
|
|
8
|
+
require_relative "reasoning_history"
|
|
9
|
+
require_relative "sampling_settings"
|
|
10
|
+
require_relative "served_model"
|
|
11
|
+
require_relative "steer_cut"
|
|
12
|
+
require_relative "steer_cut_after"
|
|
13
|
+
require_relative "thinking"
|
|
14
|
+
require_relative "vision_support"
|
|
15
|
+
|
|
16
|
+
module Samagotchi
|
|
17
|
+
# The model a session runs on, as set: the name (maybe an alias;
|
|
18
|
+
# HostRegistry#resolve applies it) and the name as typed at start or at
|
|
19
|
+
# the last /model. One value, swapped in one assignment, so a reader on
|
|
20
|
+
# another thread never pairs one model's name with another's typed name.
|
|
21
|
+
ModelChoice = Data.define(:name, :typed)
|
|
22
|
+
|
|
23
|
+
# The effective model resolved once (HostRegistry#resolve: its host and
|
|
24
|
+
# client, the id sent) with the names its models: entry may be under
|
|
25
|
+
# (HostRegistry#lookup_names), and its settings read from them. An
|
|
26
|
+
# immutable snapshot built per read, never kept across reads: config.yml,
|
|
27
|
+
# a model listing (which may move a bare id to another host) and another
|
|
28
|
+
# process's saved lists all change what a resolve gives without any
|
|
29
|
+
# /model. Frozen, so any thread may hold one.
|
|
30
|
+
#
|
|
31
|
+
# Each setting reader rescues its own failure and gives the fallback the
|
|
32
|
+
# Engine has always given (Unresolved); building one raises as
|
|
33
|
+
# HostRegistry#resolve does (the Engine's #model_setting gives the
|
|
34
|
+
# fallback then).
|
|
35
|
+
EffectiveModel = Data.define(:registry, :name, :typed, :target, :names) do
|
|
36
|
+
# @param registry [HostRegistry]
|
|
37
|
+
# @param choice [ModelChoice]
|
|
38
|
+
# @raise what HostRegistry#resolve raises
|
|
39
|
+
def self.of(registry, choice)
|
|
40
|
+
target = registry.resolve(choice.name)
|
|
41
|
+
new(registry: registry, name: choice.name, typed: choice.typed, target: target,
|
|
42
|
+
names: registry.lookup_names(choice.typed, resolved: choice.name, target: target))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A saved session's model (no worker): its model name, the default when
|
|
46
|
+
# blank, and the name it was typed as.
|
|
47
|
+
# @param session [Session]
|
|
48
|
+
def self.of_session(session, registry:)
|
|
49
|
+
name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
|
|
50
|
+
of(registry, ModelChoice.new(name: name, typed: session.model_typed || name))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def entry = target.entry
|
|
54
|
+
def client = target.client
|
|
55
|
+
def bare = target.bare_model
|
|
56
|
+
def host_name = entry.name
|
|
57
|
+
|
|
58
|
+
# A chat host (api: openai or anthropic), whose loop uses no prompt profile.
|
|
59
|
+
def chat? = entry.chat?
|
|
60
|
+
|
|
61
|
+
# The resolved ref a session stores (ModelRef#ref): no resolve.
|
|
62
|
+
def ref = registry.model_ref(name).ref
|
|
63
|
+
|
|
64
|
+
# The configured request parameters (hosts: and models: sampling).
|
|
65
|
+
def sampling
|
|
66
|
+
SamplingSettings.for(target, names: names)
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
unresolved(:sampling_unresolved, e, SamplingSettings::EMPTY)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# "temperature=0.6 (hosts.work)", nil when none.
|
|
72
|
+
def sampling_summary
|
|
73
|
+
SamplingSettings.summary(target, names: names)
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
unresolved(:sampling_unresolved, e, nil)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The thinking level (Thinking.resolve), the session's +own+ first.
|
|
79
|
+
def thinking(own:)
|
|
80
|
+
Thinking.resolve(target, names: names, session: own).first
|
|
81
|
+
rescue StandardError => e
|
|
82
|
+
unresolved(:thinking_unresolved, e, Thinking::DEFAULT)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# The thinking level with where it came from (SettingExplained), nil
|
|
86
|
+
# when it can't be resolved.
|
|
87
|
+
def thinking_explained(own:)
|
|
88
|
+
Thinking.explain(target, names: names, session: own)
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
unresolved(:thinking_unresolved, e, nil)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# What a thinking level change costs the prompt cache
|
|
94
|
+
# (Thinking.cache_cost); the block gives the prompt profile, asked only
|
|
95
|
+
# on a native host. Raises.
|
|
96
|
+
# @return [Symbol]
|
|
97
|
+
def thinking_cache_cost(&)
|
|
98
|
+
Thinking.cache_cost(target, profile: entry&.chat? ? nil : yield&.name)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The reasoning_history level (ReasoningHistory.resolve), the session's
|
|
102
|
+
# +own+ first.
|
|
103
|
+
def reasoning_history(own:)
|
|
104
|
+
ReasoningHistory.resolve(target, names: names, session: own).first
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
unresolved(:reasoning_history_unresolved, e, ReasoningHistory::DEFAULT)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The reasoning_history level with where it came from (SettingExplained);
|
|
110
|
+
# nil on a native host unless +native+ (its loop ignores the level), or
|
|
111
|
+
# when it can't be resolved.
|
|
112
|
+
def reasoning_history_explained(own:, native: false)
|
|
113
|
+
return nil unless native || entry&.chat?
|
|
114
|
+
|
|
115
|
+
ReasoningHistory.explain(target, names: names, session: own)
|
|
116
|
+
rescue StandardError => e
|
|
117
|
+
unresolved(:reasoning_history_unresolved, e, nil)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# "drop (models: qwen)"; nil for the default, on a native host, or when
|
|
121
|
+
# it can't be resolved.
|
|
122
|
+
def reasoning_history_summary(own:)
|
|
123
|
+
return nil unless entry&.chat?
|
|
124
|
+
|
|
125
|
+
level, source = ReasoningHistory.resolve(target, names: names, session: own)
|
|
126
|
+
source ? ReasoningHistory.label(level, source) : nil
|
|
127
|
+
rescue StandardError => e
|
|
128
|
+
unresolved(:reasoning_history_unresolved, e, nil)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# What a reasoning_history change from +from+ to +to+ costs the prompt
|
|
132
|
+
# cache, given +conversation+ (ReasoningHistory.cache_cost).
|
|
133
|
+
def reasoning_history_cache_cost(from, to, conversation:)
|
|
134
|
+
ReasoningHistory.cache_cost(from, to, target: target, conversation: conversation)
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
unresolved(:reasoning_history_unresolved, e, nil)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# [seconds, source] a cut waits for thinking (SteerCutAfter.resolve);
|
|
140
|
+
# the global and its name when it can't be resolved.
|
|
141
|
+
def steer_cut_after_explained
|
|
142
|
+
SteerCutAfter.resolve(target, names: names)
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
unresolved(:steer_cut_after_unresolved, e, [SteerCut.cut_after, "steer.cut_after"])
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The LLM context strategy with where each value came from
|
|
148
|
+
# (LLMContextStrategy.explain) under the session's +args+
|
|
149
|
+
# (LLMContextOverride#resolve_args), nil when it can't be resolved.
|
|
150
|
+
def llm_context_explained(args)
|
|
151
|
+
LLMContextStrategy.explain(target, names: names, **args)
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
unresolved(:llm_context_unresolved, e, nil)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The LLM context layers its turns run under; [] when they can't be
|
|
157
|
+
# resolved.
|
|
158
|
+
def llm_context_layers(args)
|
|
159
|
+
LLMContextStrategy.resolve(target, names: names, **args).active_layers
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
unresolved(:llm_context_unresolved, e, [])
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# models:/hosts: window_tokens (ContextWindow.setting), nil when unset.
|
|
165
|
+
def window_setting
|
|
166
|
+
ContextWindow.setting(target, names: names)
|
|
167
|
+
rescue StandardError => e
|
|
168
|
+
unresolved(:window_unresolved, e, nil)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The window as its loop would see it (ChatLoop#context_window: a remote
|
|
172
|
+
# chat host has no /props, only its model list); nil when it can't be
|
|
173
|
+
# resolved.
|
|
174
|
+
def context_window
|
|
175
|
+
client = self.client
|
|
176
|
+
adapter = nil
|
|
177
|
+
if entry.chat?
|
|
178
|
+
adapter = registry.adapter_for(entry)
|
|
179
|
+
client = nil if adapter.remote?
|
|
180
|
+
end
|
|
181
|
+
ContextWindow.resolve(client: client, model: bare, adapter: adapter, setting: ContextWindow.setting(target, names: names))
|
|
182
|
+
rescue StandardError => e
|
|
183
|
+
unresolved(:window_unresolved, e, nil)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Its price on its host (hosts.<name>.models.<id>.price), by the id
|
|
187
|
+
# asked for, never the one served; nil when none.
|
|
188
|
+
def price
|
|
189
|
+
entry.models&.dig(bare.to_s.strip.downcase)&.price
|
|
190
|
+
rescue StandardError => e
|
|
191
|
+
unresolved(:price_unresolved, e, nil)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# [served, +asked+] from llama.cpp's /props (one short cached probe),
|
|
195
|
+
# [nil, nil] for a remote chat host, without an answer, or when it
|
|
196
|
+
# can't be asked.
|
|
197
|
+
def served_from_props(asked)
|
|
198
|
+
if entry.chat? && registry.adapter_for(entry).remote?
|
|
199
|
+
return [nil, nil]
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
served = ServedModel.from_props(client.server_props(model: bare))
|
|
203
|
+
served ? [served, asked] : [nil, nil]
|
|
204
|
+
rescue StandardError => e
|
|
205
|
+
# The model resolved; asking its server failed (its /props, its adapter).
|
|
206
|
+
unresolved(:served_probe_failed, e, [nil, nil])
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# The host whose hosts.<name>.models.<id>.served names +served+
|
|
210
|
+
# (ServedModel.expected_by), nil when none does.
|
|
211
|
+
def served_expected_by(served)
|
|
212
|
+
ServedModel.expected_by(target, served)
|
|
213
|
+
rescue StandardError => e
|
|
214
|
+
unresolved(:served_unresolved, e, nil)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# What a local native host's cached /props names when it isn't the
|
|
218
|
+
# model asked for; nil for a chat or remote host, or before any probe.
|
|
219
|
+
def prompt_served
|
|
220
|
+
return nil if entry.nil? || entry.chat? || entry.remote? || !client.respond_to?(:cached_server_props)
|
|
221
|
+
|
|
222
|
+
served = ServedModel.from_props(client.cached_server_props(model: bare))
|
|
223
|
+
served if ServedModel.differs?(bare, served)
|
|
224
|
+
rescue StandardError => e
|
|
225
|
+
unresolved(:served_unresolved, e, nil)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Whether it sees images (VisionSupport.for) under +profile+.
|
|
229
|
+
def vision_support(profile)
|
|
230
|
+
VisionSupport.for(target, profile: profile, adapter: vision_adapter, names: names)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# A chat host's adapter (what it says of images), nil on a native host.
|
|
234
|
+
def vision_adapter
|
|
235
|
+
entry.chat? ? registry.adapter_for(entry) : nil
|
|
236
|
+
rescue StandardError => e
|
|
237
|
+
unresolved(:vision_adapter_unresolved, e, nil)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Which prompt profile it gets (ModelProfile.resolve: may probe /props).
|
|
241
|
+
# Not cached here. Raises.
|
|
242
|
+
# @return [ModelProfile::Resolution]
|
|
243
|
+
def profile_resolution
|
|
244
|
+
ModelProfile.resolve(names: names, entry: entry, client: client, bare_model: bare)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# See Unresolved.fallback.
|
|
248
|
+
def unresolved(event, error, value) = EffectiveModel::Unresolved.fallback(event, self, error, value)
|
|
249
|
+
private :unresolved
|
|
250
|
+
|
|
251
|
+
# The name, typed name and host only: the default would print the
|
|
252
|
+
# registry and the client into any error message.
|
|
253
|
+
def inspect = "#<Samagotchi::EffectiveModel #{name.inspect} typed=#{typed.inspect} host=#{entry&.name.inspect}>"
|
|
254
|
+
alias_method :to_s, :inspect
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
class EffectiveModel
|
|
258
|
+
# The fallback an effective model's reader gives when its setting
|
|
259
|
+
# can't be resolved, with a log line: WARN the first time per process
|
|
260
|
+
# for an (event, model, error class), DEBUG after. The session state is
|
|
261
|
+
# read on every Bridge /state and SSE reset, so a broken host config
|
|
262
|
+
# would otherwise WARN per poll.
|
|
263
|
+
module Unresolved
|
|
264
|
+
@seen = Set.new
|
|
265
|
+
@mutex = Mutex.new
|
|
266
|
+
|
|
267
|
+
# @param event [Symbol] the log event (<setting>_unresolved)
|
|
268
|
+
# @param model [EffectiveModel, String] the model (or its name)
|
|
269
|
+
# @param error [StandardError]
|
|
270
|
+
# @return +value+
|
|
271
|
+
def self.fallback(event, model, error, value)
|
|
272
|
+
name = model.respond_to?(:name) ? model.name : model.to_s
|
|
273
|
+
first = @mutex.synchronize { @seen.add?([event, name, error.class.name]) }
|
|
274
|
+
fields = { model: name, error: error.class.name, msg: error.message.to_s[0, 200] }
|
|
275
|
+
first ? Log.warn(:model, event.to_s, **fields) : Log.debug(:model, event.to_s, **fields)
|
|
276
|
+
value
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Forget what was logged (specs).
|
|
280
|
+
def self.reset! = @mutex.synchronize { @seen.clear }
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|