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.
Files changed (151) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +80 -1
  3. data/docs/architecture.md +55 -30
  4. data/docs/cli.md +3 -1
  5. data/docs/configuration.md +50 -9
  6. data/docs/context.md +35 -1
  7. data/docs/desktop.md +90 -22
  8. data/docs/guardrails.md +8 -5
  9. data/docs/hooks.md +2 -2
  10. data/docs/internals/anthropic.md +156 -0
  11. data/docs/internals/background-tasks.md +1 -1
  12. data/docs/internals/bundles.md +2 -0
  13. data/docs/internals/engine.md +129 -38
  14. data/docs/internals/gemma4-contract.md +1 -1
  15. data/docs/internals/llm-context-bench.md +5 -4
  16. data/docs/internals/prompt-caching.md +26 -11
  17. data/docs/plugins.md +126 -23
  18. data/docs/sessions.md +6 -6
  19. data/docs/sub-agent.md +4 -1
  20. data/docs/testing.md +18 -4
  21. data/lib/samagotchi/bootstrap/probe.rb +4 -3
  22. data/lib/samagotchi/bootstrap_command.rb +13 -3
  23. data/lib/samagotchi/bridge/ring_buffer.rb +0 -5
  24. data/lib/samagotchi/bridge.rb +6 -3
  25. data/lib/samagotchi/bundles/claude-peer/manifest.yml +7 -6
  26. data/lib/samagotchi/bundles/claude-peer/plugin.rb +34 -313
  27. data/lib/samagotchi/bundles/claude-peer/scripts/chi-peer-SKILL.md +8 -3
  28. data/lib/samagotchi/bundles/claude-peer/skill_claude_peer.md +14 -6
  29. data/lib/samagotchi/bundles/codex-peer/manifest.yml +7 -6
  30. data/lib/samagotchi/bundles/codex-peer/plugin.rb +37 -312
  31. data/lib/samagotchi/bundles/codex-peer/scripts/chi-peer-SKILL.md +8 -3
  32. data/lib/samagotchi/bundles/codex-peer/skill_codex_peer.md +14 -6
  33. data/lib/samagotchi/bundles/mcp/manifest.yml +5 -5
  34. data/lib/samagotchi/bundles/mcp/plugin.rb +82 -294
  35. data/lib/samagotchi/bundles/system/config_modification_protocol.md +7 -3
  36. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  37. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  38. data/lib/samagotchi/config.rb +15 -7
  39. data/lib/samagotchi/context_absorber.rb +66 -13
  40. data/lib/samagotchi/desktop/macos/AgentTerminal.swift +260 -0
  41. data/lib/samagotchi/desktop/macos/App.swift +3 -3
  42. data/lib/samagotchi/desktop/macos/ChiRunner.swift +5 -2
  43. data/lib/samagotchi/desktop/macos/Images.swift +2 -2
  44. data/lib/samagotchi/desktop/macos/Kitty.swift +33 -197
  45. data/lib/samagotchi/desktop/macos/Panel.swift +118 -82
  46. data/lib/samagotchi/desktop/macos/ProcessRunner.swift +1 -1
  47. data/lib/samagotchi/desktop/macos/Tmux.swift +239 -0
  48. data/lib/samagotchi/desktop/macos.rb +84 -13
  49. data/lib/samagotchi/desktop_command.rb +14 -3
  50. data/lib/samagotchi/effective_model.rb +283 -0
  51. data/lib/samagotchi/engine.rb +307 -549
  52. data/lib/samagotchi/engine_peers.rb +3 -0
  53. data/lib/samagotchi/guardrail_wiring.rb +1 -1
  54. data/lib/samagotchi/guardrails/approval.rb +1 -1
  55. data/lib/samagotchi/guardrails/approvals.rb +1 -1
  56. data/lib/samagotchi/guardrails/rules.rb +1 -1
  57. data/lib/samagotchi/guardrails/targets.rb +1 -1
  58. data/lib/samagotchi/hook_routes.rb +151 -0
  59. data/lib/samagotchi/hooks/registry.rb +2 -2
  60. data/lib/samagotchi/host_keys.rb +147 -0
  61. data/lib/samagotchi/level_setting_command.rb +99 -0
  62. data/lib/samagotchi/llm/adapter_common.rb +138 -0
  63. data/lib/samagotchi/llm/anthropic_messages.rb +28 -105
  64. data/lib/samagotchi/llm/api_key.rb +23 -7
  65. data/lib/samagotchi/llm/chat_loop.rb +10 -6
  66. data/lib/samagotchi/llm/openai_chat.rb +9 -92
  67. data/lib/samagotchi/llm/refusal_memo.rb +29 -0
  68. data/lib/samagotchi/llm_context_override.rb +1 -0
  69. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  70. data/lib/samagotchi/peer_sources.rb +58 -0
  71. data/lib/samagotchi/plugin/api.rb +64 -155
  72. data/lib/samagotchi/plugin/loader.rb +4 -2
  73. data/lib/samagotchi/plugin/peer.rb +394 -0
  74. data/lib/samagotchi/plugin/tool_spec.rb +166 -0
  75. data/lib/samagotchi/plugin_tasks.rb +2 -2
  76. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  77. data/lib/samagotchi/row_refs.rb +1 -1
  78. data/lib/samagotchi/self_report.rb +5 -3
  79. data/lib/samagotchi/send_command.rb +21 -11
  80. data/lib/samagotchi/session.rb +22 -8
  81. data/lib/samagotchi/session_continue.rb +58 -15
  82. data/lib/samagotchi/session_inbox.rb +0 -13
  83. data/lib/samagotchi/session_setting.rb +86 -0
  84. data/lib/samagotchi/thinking_command.rb +24 -76
  85. data/lib/samagotchi/thinking_notices.rb +169 -0
  86. data/lib/samagotchi/tool_call_parser.rb +1 -1
  87. data/lib/samagotchi/toolkit_binding.rb +138 -0
  88. data/lib/samagotchi/tools/context_read.rb +14 -0
  89. data/lib/samagotchi/tools/execute.rb +12 -12
  90. data/lib/samagotchi/tools/registry.rb +1 -1
  91. data/lib/samagotchi/tools/task_create.rb +6 -6
  92. data/lib/samagotchi/tools/task_get.rb +13 -13
  93. data/lib/samagotchi/tools/task_list.rb +6 -6
  94. data/lib/samagotchi/tools/task_record.rb +52 -0
  95. data/lib/samagotchi/tools/task_runtime.rb +55 -91
  96. data/lib/samagotchi/tools/task_stop.rb +6 -6
  97. data/lib/samagotchi/tools/task_wait.rb +12 -13
  98. data/lib/samagotchi/tools/tool_index.rb +454 -0
  99. data/lib/samagotchi/update_command.rb +9 -6
  100. data/lib/samagotchi/version.rb +1 -1
  101. data/lib/samagotchi/web/app.rb +71 -93
  102. data/lib/samagotchi/web/capabilities.rb +1 -1
  103. data/lib/samagotchi/web/public/activity.js +2 -2
  104. data/lib/samagotchi/web/public/annotate_actions.js +1 -1
  105. data/lib/samagotchi/web/public/app.js +103 -539
  106. data/lib/samagotchi/web/public/bubbles.js +291 -0
  107. data/lib/samagotchi/web/public/cards_place.js +184 -0
  108. data/lib/samagotchi/web/public/chains.js +1 -1
  109. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  110. data/lib/samagotchi/web/public/command_list.js +12 -10
  111. data/lib/samagotchi/web/public/commands.js +2 -2
  112. data/lib/samagotchi/web/public/composer_size.js +2 -2
  113. data/lib/samagotchi/web/public/copy.js +1 -1
  114. data/lib/samagotchi/web/public/ctx.js +1 -1
  115. data/lib/samagotchi/web/public/delivery_line.js +4 -4
  116. data/lib/samagotchi/web/public/families_view.js +1 -1
  117. data/lib/samagotchi/web/public/format.js +11 -7
  118. data/lib/samagotchi/web/public/images.js +25 -2
  119. data/lib/samagotchi/web/public/index.html +15 -7
  120. data/lib/samagotchi/web/public/info_bar.js +3 -3
  121. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  122. data/lib/samagotchi/web/public/init_row.js +21 -10
  123. data/lib/samagotchi/web/public/level_chip.js +29 -80
  124. data/lib/samagotchi/web/public/lists_view.js +64 -9
  125. data/lib/samagotchi/web/public/llm_context_chip.js +38 -107
  126. data/lib/samagotchi/web/public/model_picker.js +26 -22
  127. data/lib/samagotchi/web/public/notify.js +6 -4
  128. data/lib/samagotchi/web/public/question_card.js +1 -1
  129. data/lib/samagotchi/web/public/ref_scan.js +2 -8
  130. data/lib/samagotchi/web/public/refs.js +13 -0
  131. data/lib/samagotchi/web/public/session_state.js +14 -0
  132. data/lib/samagotchi/web/public/session_title.js +2 -3
  133. data/lib/samagotchi/web/public/sessions_list.js +0 -8
  134. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  135. data/lib/samagotchi/web/public/stage_model.js +35 -2
  136. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  137. data/lib/samagotchi/web/public/stage_view.js +56 -335
  138. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  139. data/lib/samagotchi/web/public/strip.js +3 -3
  140. data/lib/samagotchi/web/public/task_stop.js +3 -2
  141. data/lib/samagotchi/web/public/turn_events.js +1 -1
  142. data/lib/samagotchi/web/public/turn_html.js +2 -2
  143. data/lib/samagotchi/web/public/turn_model.js +2 -2
  144. data/lib/samagotchi/web/public/turn_view.js +1 -1
  145. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  146. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  147. data/lib/samagotchi/web/server.rb +0 -4
  148. data/lib/samagotchi/worker.rb +22 -24
  149. data/lib/samagotchi/worker_inbound.rb +36 -4
  150. data/lib/samagotchi/worker_wakes.rb +67 -7
  151. 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, kitty: kitty_settings, broadcast_timeout: broadcast_timeout)
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, kitty: 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
- @kitty = kitty
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. The kitty section only
118
- # when kitty.listen_on is set; the broadcast timeout when given.
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["kitty"] = @kitty if @kitty
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 kitty settings or broadcast timeout: refresh_launch_file fixes
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"] || launch["kitty"] != @kitty ||
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
- return [] unless File.file?(File.join(@source_dir, ".git"))
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
- ["chi runs from a linked git worktree (#{@source_dir}); the helper stops working once it is removed. " \
169
- "Install from the main checkout or a gem install."]
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
- @stdout.puts(format_rows([["Chi Helper", version], ["app", s[:app_path]], ["runs chi", launch],
125
- ["state dirs", dirs], ["service", service], ["hotkey", hotkey(s[:hotkey])],
126
- ["process", running], ["at login", login(s[:login])]]))
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