samagotchi 0.67.0 → 0.69.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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -1
  3. data/docs/architecture.md +7 -4
  4. data/docs/cli.md +6 -1
  5. data/docs/configuration.md +28 -6
  6. data/docs/guardrails.md +23 -1
  7. data/docs/hooks.md +4 -0
  8. data/docs/internals/bundles.md +1 -1
  9. data/docs/internals/engine.md +73 -17
  10. data/docs/internals/prompt-caching.md +9 -2
  11. data/docs/plugins.md +42 -19
  12. data/docs/releasing.md +14 -6
  13. data/docs/sessions.md +3 -3
  14. data/docs/testing.md +8 -2
  15. data/lib/samagotchi/bootstrap/probe.rb +1 -3
  16. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  17. data/lib/samagotchi/bundles/system/self_map.md +1 -1
  18. data/lib/samagotchi/config.rb +0 -19
  19. data/lib/samagotchi/effective_model.rb +283 -0
  20. data/lib/samagotchi/engine.rb +216 -360
  21. data/lib/samagotchi/guardrails/targets.rb +9 -2
  22. data/lib/samagotchi/host_config.rb +1 -29
  23. data/lib/samagotchi/host_model.rb +0 -7
  24. data/lib/samagotchi/kernel_loop.rb +24 -7
  25. data/lib/samagotchi/level_setting_command.rb +99 -0
  26. data/lib/samagotchi/llm/errors.rb +47 -1
  27. data/lib/samagotchi/llm/http.rb +20 -2
  28. data/lib/samagotchi/llm_context_stale.rb +6 -0
  29. data/lib/samagotchi/llm_context_strategy.rb +5 -10
  30. data/lib/samagotchi/memory_bundle/manifest.rb +3 -0
  31. data/lib/samagotchi/model_price.rb +2 -4
  32. data/lib/samagotchi/reasoning_history_command.rb +29 -90
  33. data/lib/samagotchi/session.rb +22 -8
  34. data/lib/samagotchi/session_metrics.rb +4 -1
  35. data/lib/samagotchi/session_setting.rb +86 -0
  36. data/lib/samagotchi/system_prompt.rb +19 -3
  37. data/lib/samagotchi/terminal_ui/formatting.rb +5 -2
  38. data/lib/samagotchi/thinking_command.rb +24 -76
  39. data/lib/samagotchi/thinking_notices.rb +169 -0
  40. data/lib/samagotchi/tool_activity.rb +3 -0
  41. data/lib/samagotchi/tool_row_fields.rb +4 -2
  42. data/lib/samagotchi/tool_runner.rb +24 -3
  43. data/lib/samagotchi/toolkit.rb +27 -14
  44. data/lib/samagotchi/toolkit_binding.rb +8 -2
  45. data/lib/samagotchi/toolkit_command.rb +20 -4
  46. data/lib/samagotchi/tools/builtin_calls.rb +45 -1
  47. data/lib/samagotchi/tools/registry.rb +95 -8
  48. data/lib/samagotchi/tools/tool_index.rb +223 -72
  49. data/lib/samagotchi/version.rb +1 -1
  50. data/lib/samagotchi/web/app.rb +69 -93
  51. data/lib/samagotchi/web/message_parts.rb +5 -2
  52. data/lib/samagotchi/web/public/activity.js +3 -1
  53. data/lib/samagotchi/web/public/app.js +196 -526
  54. data/lib/samagotchi/web/public/bubbles.js +291 -0
  55. data/lib/samagotchi/web/public/cards_place.js +184 -0
  56. data/lib/samagotchi/web/public/chains.js +19 -2
  57. data/lib/samagotchi/web/public/chip_popover.js +111 -0
  58. data/lib/samagotchi/web/public/command_list.js +12 -10
  59. data/lib/samagotchi/web/public/families_view.js +15 -0
  60. data/lib/samagotchi/web/public/index.html +21 -11
  61. data/lib/samagotchi/web/public/info_bar.js +3 -3
  62. data/lib/samagotchi/web/public/info_bar_model.js +110 -0
  63. data/lib/samagotchi/web/public/init_row.js +21 -10
  64. data/lib/samagotchi/web/public/level_chip.js +29 -80
  65. data/lib/samagotchi/web/public/lists_view.js +64 -9
  66. data/lib/samagotchi/web/public/llm_context_chip.js +36 -105
  67. data/lib/samagotchi/web/public/model_picker.js +47 -28
  68. data/lib/samagotchi/web/public/ref_scan.js +1 -7
  69. data/lib/samagotchi/web/public/refs.js +13 -0
  70. data/lib/samagotchi/web/public/session_state.js +5 -0
  71. data/lib/samagotchi/web/public/stage_frame.js +179 -0
  72. data/lib/samagotchi/web/public/stage_model.js +33 -0
  73. data/lib/samagotchi/web/public/stage_slots.js +130 -0
  74. data/lib/samagotchi/web/public/stage_view.js +56 -335
  75. data/lib/samagotchi/web/public/stage_watch.js +65 -0
  76. data/lib/samagotchi/web/public/stream_handlers.js +5 -2
  77. data/lib/samagotchi/web/public/timing.js +3 -1
  78. data/lib/samagotchi/web/public/toast_place.js +16 -0
  79. data/lib/samagotchi/web/public/toolkit_chip.js +11 -4
  80. data/lib/samagotchi/web/public/turn_html.js +4 -2
  81. data/lib/samagotchi/web/public/vendor/directive-helpers.js +8 -0
  82. data/lib/samagotchi/web/public/vendor/directives/repeat.js +9 -0
  83. data/lib/samagotchi/worker.rb +11 -16
  84. metadata +15 -1
@@ -3,6 +3,7 @@
3
3
  require_relative "../tools/tool_path"
4
4
  require_relative "../tools/memory"
5
5
  require_relative "../tool_declarations"
6
+ require_relative "../tools/tool_index"
6
7
  require_relative "../log"
7
8
  require_relative "model_size"
8
9
  require_relative "outside"
@@ -147,14 +148,20 @@ module Samagotchi
147
148
 
148
149
  # +name+ as the acts_as of +entry+'s calls: nil for none, and for a
149
150
  # core tool or a tool another bundle registered (logged); the tool's
150
- # own name is no alias either.
151
+ # own name is no alias either. chi's tool index (call_tool, source
152
+ # ToolIndex::SOURCE) skips the other-bundle check: its call_targets
153
+ # only name a tool one of its sources published (or a source-prefixed
154
+ # name, ToolIndex#claimed), so the registry is the wrong owner test,
155
+ # and a bundle tool sharing an MCP tool's name would otherwise take
156
+ # call_tool calls of that tool out of its mcp_* rules.
151
157
  def self.acts_as_of(name, entry, registry)
152
158
  name = name.to_s.strip
153
159
  return nil if name.empty? || name == entry.name
154
160
 
155
161
  other = registry[name]
156
162
  whose = if CORE_TOOLS.include?(name) || other&.core? then "a core tool"
157
- elsif other && other.source != entry.source then "bundle #{other.source}'s tool"
163
+ elsif other && other.source != entry.source && entry.source != Tools::ToolIndex::SOURCE
164
+ "bundle #{other.source}'s tool"
158
165
  end
159
166
  return name unless whose
160
167
 
@@ -10,9 +10,7 @@ require_relative "reasoning_history"
10
10
  module Samagotchi
11
11
  # One config.yml hosts: entry, parsed and checked (ConfigFile.hosts_config
12
12
  # holds them by lowercased name; HostRegistry builds its HostEntry from
13
- # one). #to_env_h writes every field in the shape of the
14
- # SAMAGOTCHI_HOSTS_JSON override (ConfigFile.hosts_json_for_env), and
15
- # .parse reads it back to an equal HostConfig.
13
+ # one).
16
14
  #
17
15
  # name: the lowercased entry name; host, port, scheme: where it is (from
18
16
  # url: when given); url: the configured url, nil when host/port; transport,
@@ -179,31 +177,5 @@ module Samagotchi
179
177
  nil
180
178
  end
181
179
  private_class_method :invalid
182
-
183
- # The entry as a SAMAGOTCHI_HOSTS_JSON override carries it (string keys,
184
- # unset fields left out); .parse(name, to_env_h) gives it back. A url
185
- # entry travels as its url (host/port come from it); the API key stays
186
- # in the environment.
187
- # @return [Hash{String => Object}]
188
- def to_env_h
189
- location = url ? { "url" => url } : { "host" => host, "port" => port }
190
- location.merge("transport" => transport&.to_s, "api" => api&.to_s, "api_key_env" => api_key_env,
191
- "auth" => (AUTH_SCHEMES.key(auth) if auth), "dialect" => dialect,
192
- "profile" => profile, "first_token_timeout" => first_token_timeout,
193
- "vision" => vision, "sampling" => sampling, "thinking" => thinking&.to_s,
194
- "reasoning_history" => reasoning_history&.to_s, "remote" => remote, "window_tokens" => window_tokens,
195
- "steer_cut_after" => steer_cut_after,
196
- "llm_context_strategy" => llm_context_strategy&.map(&:to_s),
197
- "llm_context_apply" => llm_context_apply&.to_s,
198
- "llm_context_budget_tokens" => llm_context_budget_tokens,
199
- "models" => models_config).compact
200
- end
201
-
202
- private
203
-
204
- # hosts.<name>.models as written back (HostModel#to_config), nil when none.
205
- def models_config
206
- models.values.to_h { |m| [m.id, m.to_config] } unless models.nil? || models.empty?
207
- end
208
180
  end
209
181
  end
@@ -103,13 +103,6 @@ module Samagotchi
103
103
  .map { |info| HostModel::Row.new(id: info.id.to_s, configured: false, info: info) }
104
104
  end
105
105
  end
106
-
107
- # The entry as written back to config (HostConfig#to_env_h): nil for a
108
- # bare id.
109
- def to_config
110
- config = { "price" => price&.to_config, "served" => served }.compact
111
- config unless config.empty?
112
- end
113
106
  end
114
107
 
115
108
  # A listing row (HostModel.rows): id as the host lists it, else as
@@ -21,6 +21,7 @@ require_relative "steer"
21
21
  require_relative "reasoning_history"
22
22
  require_relative "thought_stream_splitter"
23
23
  require_relative "tools/builtins"
24
+ require_relative "tools/builtin_calls"
24
25
  require_relative "muted_memories"
25
26
  require_relative "tool_activity"
26
27
  require_relative "tool_runner"
@@ -1070,12 +1071,22 @@ module Samagotchi
1070
1071
  #
1071
1072
  # A tool the session's toolkit leaves out is a real one, so its error
1072
1073
  # does name it (deliberately, against the rule above): the model learns
1073
- # it exists and that only the user can load it.
1074
- def unknown_tool_error(name, layers)
1074
+ # it exists, and how to reach it (call_tool, when the toolkit reaches
1075
+ # it that way) or that only the user can load it. A call reached
1076
+ # through call_tool (+via+) of a tool the toolkit declares is told to
1077
+ # call it directly: the model's call_tool(tool: "chi/read") with read
1078
+ # declared, or a hook's rewrite (a direct call can't carry via:).
1079
+ def unknown_tool_error(name, layers, via: nil)
1075
1080
  names = @tools.names(layers: layers)
1076
- if @tools.respond_to?(:left_out) && @tools.left_out(name, layers: layers)
1077
- toolkit = @tools.toolkit&.name
1078
- "Error: #{name} isn't loaded in this session#{" (toolkit #{toolkit})" if toolkit} and can't be loaded from " \
1081
+ toolkit = @tools.toolkit&.name
1082
+ in_toolkit = " in this session#{" (toolkit #{toolkit})" if toolkit}"
1083
+ if via && @tools.offered(name, layers: layers)
1084
+ "Error: #{name} is declared#{in_toolkit}; call it directly. Available: #{names.join(", ")}"
1085
+ elsif !via && @tools.respond_to?(:indexed) && @tools.indexed(name, layers: layers)
1086
+ "Error: #{name} isn't declared#{in_toolkit}; call it with #{Tools::BuiltinCalls::VIA}(tool: \"chi/#{name}\", args: {…}) " \
1087
+ "(find_tools gives its inputSchema). Available: #{names.join(", ")}"
1088
+ elsif @tools.respond_to?(:left_out) && @tools.left_out(name, layers: layers)
1089
+ "Error: #{name} isn't loaded#{in_toolkit} and can't be loaded from " \
1079
1090
  "here; only the user can (/toolkit add #{name}). Available: #{names.join(", ")}"
1080
1091
  elsif SHELL_LIKE_TOOL_NAMES.include?(name.to_s.downcase) && names.include?("execute")
1081
1092
  "Error: no such tool. Shell commands run with `execute` (same arguments)."
@@ -1085,9 +1096,15 @@ module Samagotchi
1085
1096
  end
1086
1097
 
1087
1098
  def dispatch(call)
1088
- entry = @tools.offered(call[:name], layers: llm_context_layers)
1099
+ # A built-in reached through call_tool runs only while the toolkit
1100
+ # reaches it that way (Registry#indexed); any other call by its name.
1101
+ entry = if call[:via]
1102
+ @tools.respond_to?(:indexed) ? @tools.indexed(call[:name], layers: llm_context_layers) : nil
1103
+ else
1104
+ @tools.offered(call[:name], layers: llm_context_layers)
1105
+ end
1089
1106
  unless entry
1090
- result = unknown_tool_error(call[:name], llm_context_layers)
1107
+ result = unknown_tool_error(call[:name], llm_context_layers, via: call[:via])
1091
1108
  return {
1092
1109
  output: result,
1093
1110
  activity: ToolActivity.tool_activity_event(call[:name], call, result, registry: @tools)
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "cache_confirm"
4
+
5
+ module Samagotchi
6
+ # A command for a session's own level of a setting (/thinking,
7
+ # /reasoning-history): with no argument, the level the next turn runs at
8
+ # and where it came from; with a level, sets the session's own (the
9
+ # setting's unset word unsets it). A change takes effect at the next
10
+ # turn's start (the REPL and the worker run it between turns), and the
11
+ # reply says what it costs the prompt cache. A costly change asks first
12
+ # (CacheConfirm); --yes (or -y) applies it.
13
+ #
14
+ # A subclass gives its Spec (#spec): what differs between the settings.
15
+ class LevelSettingCommand
16
+ # name: the command; label: the reply's ("thinking: low (session)");
17
+ # usage: the usage line an unknown word gets;
18
+ # parse: word → [known?, the session's own level (nil unsets)];
19
+ # own: engine → the session's own level now;
20
+ # explained: (engine, own) → SettingExplained, nil when it can't be resolved;
21
+ # set: (engine, own) → sets the session's own;
22
+ # cache_cost: (engine, from_level, to_level) → a cost_notes key, nil for none;
23
+ # cost_notes: {cost => the reply's line};
24
+ # follows: what the session follows with no own level;
25
+ # extra_line: engine → one more listing line, nil for none.
26
+ Spec = Data.define(:name, :label, :usage, :parse, :own, :explained, :set, :cache_cost, :cost_notes, :follows,
27
+ :extra_line)
28
+
29
+ # @param engine [Engine] the session's (#session, #history? and the spec's readers)
30
+ # @param save [#call] saves the session after a change
31
+ def initialize(engine:, save:)
32
+ @engine = engine
33
+ @save = save
34
+ end
35
+
36
+ # @param args [String] what follows the command (--yes anywhere)
37
+ # @param confirm [Boolean] ask before a costly change (CacheConfirm);
38
+ # false applies it at once (chi send, a delegate, a start flag)
39
+ # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
40
+ # whether the level changed, and the confirm it asks for (nothing
41
+ # changed then)
42
+ # @raise [ArgumentError] a word that isn't a level (with the usage)
43
+ def run(args, confirm: true)
44
+ word, yes = CacheConfirm.split(args)
45
+ return [listing(explained), false, nil] if word.empty?
46
+
47
+ known, own = spec.parse.call(word)
48
+ raise ArgumentError, "unknown #{spec.label} level #{word}\n#{spec.usage}" unless known
49
+
50
+ session = @engine.session or raise ArgumentError, "no session yet"
51
+ before = explained
52
+ ask = confirm && !yes ? ask(before, own, word) : nil
53
+ return ask if ask
54
+
55
+ spec.set.call(@engine, own)
56
+ @save.call(session)
57
+ after = explained
58
+ [[listing(after), *notes(before, after)].join("\n"), true, nil]
59
+ end
60
+
61
+ # The level, where it came from, and what the session sets itself.
62
+ def listing(explained)
63
+ return "#{spec.label}: can't be resolved for this model" unless explained
64
+
65
+ lines = ["#{spec.label}: #{explained.label}"]
66
+ lines << " this session's own: none (follows #{spec.follows})" unless explained.own
67
+ extra = spec.extra_line&.call(@engine)
68
+ lines << extra if extra
69
+ lines.join("\n")
70
+ end
71
+
72
+ private
73
+
74
+ def spec = self.class::SPEC
75
+
76
+ def explained(own: spec.own.call(@engine)) = spec.explained.call(@engine, own)
77
+
78
+ # The reply that asks before a costly change, nil when it needn't.
79
+ def ask(before, own, word)
80
+ would = explained(own: own)
81
+ return nil unless before && would
82
+
83
+ cost = spec.cache_cost.call(@engine, before.level, would.level)
84
+ return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
85
+
86
+ step = CacheConfirm.confirm(spec.name, word)
87
+ [[listing(before), "Switching to #{would.level} restarts the prompt cache: #{spec.cost_notes[cost]}", step.text].join("\n"),
88
+ false, step]
89
+ end
90
+
91
+ def notes(before, after)
92
+ return [] unless before && after
93
+ return ["(no change to what the next turn runs under)"] if before.level == after.level
94
+
95
+ cost = spec.cache_cost.call(@engine, before.level, after.level)
96
+ ["From the next turn's start (a running turn keeps its own).", spec.cost_notes[cost]].compact
97
+ end
98
+ end
99
+ end
@@ -20,7 +20,8 @@ module Samagotchi
20
20
  # asking again could help.
21
21
  #
22
22
  # ConnectionError no answer (refused, reset, timeout); RetryExhausted,
23
- # ConnectionRefused
23
+ # ConnectionRefused, TLSError (a handshake that can't
24
+ # work: certificate, host name, http vs https)
24
25
  # RateLimited 429, with retry_after when the server says
25
26
  # CreditsHeld 402 "… in-flight requests": credit reserved by other
26
27
  # requests; a RateLimited, retried after 20 s
@@ -271,6 +272,51 @@ module Samagotchi
271
272
  def summary = "can't reach host #{host}#{" at #{address}" if address} (connection refused) — is the server running?"
272
273
  end
273
274
 
275
+ # A TLS handshake that can't work however often it is tried: a
276
+ # certificate chi doesn't trust, a certificate for another name, or a
277
+ # server that doesn't speak TLS there (an https:// base_url on a plain
278
+ # HTTP port). Not retried. Any other OpenSSL::SSL::SSLError (an
279
+ # unexpected eof or a reset mid-handshake) is a network blip, retried.
280
+ class TLSError < ConnectionError
281
+ # problem => [pattern of the OpenSSL message, what to check]. The
282
+ # first match wins, so the host name comes before the general
283
+ # certificate check ("certificate verify failed (hostname mismatch)").
284
+ PROBLEMS = {
285
+ hostname: [/hostname mismatch|does not match the server certificate/i,
286
+ "the certificate is for another name: check the host's base_url"],
287
+ certificate: [/certificate verify failed/i,
288
+ "is its CA trusted? set SSL_CERT_FILE to a CA bundle that has it"],
289
+ scheme: [/wrong version number|packet length too long/i,
290
+ "the server doesn't answer TLS there: should the host's base_url start with http://?"]
291
+ }.freeze
292
+
293
+ attr_reader :address, :problem, :reason
294
+
295
+ # The permanent problem an OpenSSL::SSL::SSLError's +message+ names
296
+ # (:hostname, :certificate, :scheme), or nil for a transient one.
297
+ def self.problem_for(message)
298
+ PROBLEMS.find { |_problem, (pattern, _hint)| pattern.match?(message.to_s) }&.first
299
+ end
300
+
301
+ # The OpenSSL reason without Ruby's "SSL_connect returned=1 errno=0
302
+ # peeraddr=… state=…: " prefix.
303
+ def self.reason_for(message)
304
+ message.to_s[/\bstate=[^:]*:\s*(.+)\z/m, 1] || message.to_s
305
+ end
306
+
307
+ def initialize(host:, problem:, reason:, address: nil, attempts: 1)
308
+ @address = address
309
+ @problem = problem
310
+ @reason = reason
311
+ super("#{host}: TLS failed#{" at #{address}" if address}: #{reason}", host: host, retryable: false,
312
+ attempts: attempts)
313
+ end
314
+
315
+ def summary
316
+ "can't reach host #{host}#{" at #{address}" if address} over TLS (#{reason}) — #{PROBLEMS.fetch(problem).last}"
317
+ end
318
+ end
319
+
274
320
  # A stream that showed nothing (no text, reasoning or tool call) within
275
321
  # the host's first-token limit. A queued request can stay open for many
276
322
  # minutes on keep-alive comments alone, which reset the read timeout.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "net/http"
4
+ require "openssl"
4
5
  require "uri"
5
6
  require_relative "../config"
6
7
  require_relative "../version"
@@ -31,9 +32,13 @@ module Samagotchi
31
32
  # the user, who can try again later.
32
33
  MAX_RETRY_AFTER = 60.0
33
34
 
35
+ # Errors that mean the request didn't get through (the probe treats
36
+ # them as "unreachable"). #retrying retries them, except a refused
37
+ # connection and a TLS error that can't work (.tls_problem).
34
38
  NETWORK_ERRORS = [
35
- Timeout::Error, EOFError, SocketError, IO::TimeoutError,
36
- Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ETIMEDOUT
39
+ Timeout::Error, EOFError, SocketError, IO::TimeoutError, OpenSSL::SSL::SSLError,
40
+ Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Errno::ENETDOWN,
41
+ Errno::ETIMEDOUT, Errno::EPIPE, Errno::EADDRNOTAVAIL
37
42
  ].freeze
38
43
 
39
44
  # Requests of these purposes are the ones a turn waits on (or, for the
@@ -77,6 +82,13 @@ module Samagotchi
77
82
  NETWORK_ERRORS.any? { |klass| error.is_a?(klass) }
78
83
  end
79
84
 
85
+ # The permanent TLS problem +error+ is (TLSError::PROBLEMS: :certificate,
86
+ # :hostname, :scheme), or nil: not a TLS error, or a transient one
87
+ # (an unexpected eof or a reset mid-handshake) worth retrying.
88
+ def self.tls_problem(error)
89
+ error.is_a?(OpenSSL::SSL::SSLError) ? TLSError.problem_for(error.message) : nil
90
+ end
91
+
80
92
  # The ProviderError an SSE line carries (llama.cpp's `error:` event, a
81
93
  # `data:` line with an `error` object), or nil.
82
94
  def self.sse_error(line, host:)
@@ -278,6 +290,12 @@ module Samagotchi
278
290
  if e.is_a?(Errno::ECONNREFUSED)
279
291
  raise ConnectionRefused.new(host: @label, address: current[:address], attempts: attempts)
280
292
  end
293
+ # Nor is a handshake that can't work (an untrusted certificate,
294
+ # another name's, a plain HTTP server): it fails the same way.
295
+ if (problem = self.class.tls_problem(e))
296
+ raise TLSError.new(host: @label, address: current[:address], problem: problem,
297
+ reason: TLSError.reason_for(e.message), attempts: attempts)
298
+ end
281
299
 
282
300
  delay = current[:streamed] ? nil : @retry_policy.delay_for(attempts)
283
301
  raise RetryExhausted.new(attempts: attempts, last_error: e, label: @label) if delay.nil?
@@ -191,11 +191,17 @@ module Samagotchi
191
191
  id = entry[:tool_call_id].to_s
192
192
  matches = id.empty? ? [] : batch.select { |call_id, _| call_id.to_s == id }
193
193
  call = matches.one? ? matches.first.last : nil
194
+ call &&= Tools::BuiltinCalls.unwrap_saved(call, entry[:content])
194
195
  texts = call && refs.size == 1 ? ToolResponse.runs_named(entry[:content], [call[:name]]) : nil
195
196
  return texts ? [[refs.first, call, texts.first.text]] : []
196
197
  end
197
198
 
199
+ # A call_tool of a built-in ran as it: its run's lead names the
200
+ # built-in (BuiltinCalls.unwrap_saved).
198
201
  calls = batch.map(&:last)
202
+ calls = calls.zip(ToolResponse.runs(entry[:content], calls.size)).map do |call, run|
203
+ Tools::BuiltinCalls.unwrap_saved(call, run&.text)
204
+ end
199
205
  texts = ToolResponse.runs_named(entry[:content], calls.map { |call| call[:name] })
200
206
  return [] unless texts && texts.size == refs.size
201
207
 
@@ -211,17 +211,12 @@ module Samagotchi
211
211
  def for_session(session, registry: nil)
212
212
  return nil if session.nil?
213
213
 
214
- require_relative "config"
215
- require_relative "model_profile"
216
- registry ||= begin
217
- require_relative "host_registry"
218
- HostRegistry.new
219
- end
220
- name = session.model_name.to_s.strip.empty? ? ModelProfile.required_model_name(nil) : session.model_name
221
- target = registry.resolve(name)
222
- names = registry.lookup_names(session.model_typed || name, resolved: name, target: target)
214
+ # Here, not at the top: config.rb requires this file before ConfigFile
215
+ # exists, and effective_model.rb requires config.rb (via host_registry.rb).
216
+ require_relative "effective_model"
217
+ registry ||= HostRegistry.new
223
218
  own = session.llm_context
224
- explain(target, names: names, **(own&.resolve_args || {})).summary(own)
219
+ EffectiveModel.of_session(session, registry: registry).llm_context_explained(own&.resolve_args || {})&.summary(own)
225
220
  rescue StandardError
226
221
  nil
227
222
  end
@@ -24,6 +24,9 @@ module Samagotchi
24
24
  # file: plugin.rb
25
25
  # sha256: sha256:0a1b...
26
26
  # requires_chi: ">= 0.1.28" # optional — a Gem::Requirement for chi's VERSION
27
+ # requires_chi_next: true # optional, shipped bundles — ignored here:
28
+ # # `rake release:bump` raises requires_chi to
29
+ # # the released chi (docs/releasing.md)
27
30
  # needs: # optional — outside commands the memories rely on
28
31
  # - command: gh # (checked on PATH, never installed; docs/memory.md)
29
32
  # why: reads PRs # optional
@@ -15,8 +15,8 @@ module Samagotchi
15
15
 
16
16
  values = raw.is_a?(Hash) ? raw.transform_keys(&:to_s).slice(*ModelPrice::KEYS) : nil
17
17
  valid = values && values.key?("input") && values.key?("output") &&
18
- # YAML's .inf and .nan are Floats too: JSON can't carry them
19
- # (hosts_json_for_env would drop every host) and NaN poisons the sums.
18
+ # YAML's .inf and .nan are Floats too: an infinite price
19
+ # isn't one, and NaN poisons the sums.
20
20
  values.values.all? { |v| v.is_a?(Numeric) && v.finite? && !v.negative? }
21
21
  unless valid
22
22
  warn_once "Warning: #{where}.price needs input and output, each a number >= 0 (USD per 1M tokens); ignored"
@@ -45,8 +45,6 @@ module Samagotchi
45
45
  ((uncached * input) + (cached_tokens * cache_read) + (cache_write_tokens * cache_write) +
46
46
  (completion_tokens * output)) / 1e6
47
47
  end
48
-
49
- def to_config = to_h.transform_keys(&:to_s)
50
48
  end
51
49
 
52
50
  # The keys a price may hold (config validation checks them too).
@@ -1,20 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "cache_confirm"
3
+ require_relative "level_setting_command"
4
4
  require_relative "reasoning_history"
5
5
 
6
6
  module Samagotchi
7
7
  # /reasoning-history, the session's own reasoning_history level
8
- # (Session#reasoning_history): with no argument, the level the next turn
9
- # runs under and where it came from; `/reasoning-history drop|turn|all`
10
- # sets the session's own (an explicit turn too), `/reasoning-history
11
- # default` unsets it (the session follows SAMAGOTCHI_REASONING_HISTORY,
12
- # the model, its host, then turn). A change takes effect at the next
13
- # turn's start; a native host ignores the level (its loop keeps the
14
- # running turn's reasoning), but the session keeps it for a chat model.
15
- # A change that rewrites the earlier replies asks first (CacheConfirm);
16
- # --yes (or -y) applies it.
17
- class ReasoningHistoryCommand
8
+ # (Session#reasoning_history): `/reasoning-history drop|turn|all` sets it
9
+ # (an explicit turn too), `/reasoning-history default` unsets it (the
10
+ # session follows SAMAGOTCHI_REASONING_HISTORY, the model, its host, then
11
+ # turn). Resolved on any host: a native one ignores the level (its loop
12
+ # keeps the running turn's reasoning), but the session keeps it for a
13
+ # chat model. A change that rewrites the earlier replies asks first. See
14
+ # LevelSettingCommand.
15
+ class ReasoningHistoryCommand < LevelSettingCommand
18
16
  NAME = "/reasoning-history"
19
17
  USAGE = "usage: #{NAME} [#{(ReasoningHistory::LEVELS + [ReasoningHistory::UNSET]).join("|")}]".freeze
20
18
  NATIVE_NOTE = " (ignored on this host: the native loop keeps the running turn's reasoning)"
@@ -24,84 +22,25 @@ module Samagotchi
24
22
  "from the first earlier thought."
25
23
  }.freeze
26
24
 
27
- # @param engine [Engine] #reasoning_history_explained,
28
- # #reasoning_history_override(=), #session, #chat_model?,
29
- # #reasoning_history_cache_cost, #history?
30
- # @param save [#call] saves the session after a change
31
- def initialize(engine:, save:)
32
- @engine = engine
33
- @save = save
34
- end
35
-
36
- # @param args [String] what follows /reasoning-history (--yes anywhere)
37
- # @param confirm [Boolean] ask before a costly change (CacheConfirm);
38
- # false applies it at once
39
- # @return [Array(String, Boolean, CacheConfirm::Confirm|nil)] the reply,
40
- # whether the level changed, and the confirm it asks for (nothing
41
- # changed then)
42
- # @raise [ArgumentError] a word that isn't a level (with USAGE)
43
- def run(args, confirm: true)
44
- word, yes = CacheConfirm.split(args)
45
- return [listing(explained), false, nil] if word.empty?
46
-
47
- level = ReasoningHistory.session_level(word)
48
- raise ArgumentError, "unknown reasoning history level #{word}\n#{USAGE}" unless level || word == ReasoningHistory::UNSET
49
-
50
- session = @engine.session or raise ArgumentError, "no session yet"
51
- before = explained
52
- ask = confirm && !yes ? ask(before, level, word) : nil
53
- return ask if ask
54
-
55
- @engine.reasoning_history_override = level
56
- @save.call(session)
57
- after = explained
58
- [[listing(after), *notes(before, after)].join("\n"), true, nil]
59
- end
60
-
61
- # The level, where it came from, and what the session sets itself.
62
- def listing(explained)
63
- return "reasoning history: can't be resolved for this model" unless explained
64
-
65
- lines = ["reasoning history: #{explained.label}"]
66
- unless explained.own
67
- lines << " this session's own: none (follows #{ReasoningHistory::ENV_NAME}, the model, its host, then turn)"
68
- end
69
- lines << NATIVE_NOTE unless chat?
70
- lines.join("\n")
71
- end
72
-
73
- private
74
-
75
- # Resolved on any host: a native one ignores it, but /reasoning-history
76
- # still shows what a chat model would get.
77
- def explained(own: @engine.reasoning_history_override)
78
- @engine.reasoning_history_explained(native: true, own: own)
79
- end
80
-
81
- # The reply that asks before a costly change, nil when it needn't.
82
- def ask(before, own, word)
83
- would = explained(own: own)
84
- return nil unless before && would
85
-
86
- cost = @engine.reasoning_history_cache_cost(before.level, would.level)
87
- return nil unless CacheConfirm.needed?(cost: cost, history: @engine.history?, changed: would.level != before.level)
88
-
89
- step = CacheConfirm.confirm(NAME, word)
90
- [[listing(before), "Switching to #{would.level} restarts the prompt cache: #{COST_NOTES[cost]}", step.text].join("\n"), false, step]
91
- end
92
-
93
- def chat?
94
- @engine.chat_model?
95
- rescue StandardError
96
- false
97
- end
98
-
99
- def notes(before, after)
100
- return [] unless before && after
101
- return ["(no change to what the next turn runs under)"] if before.level == after.level
102
-
103
- cost = @engine.reasoning_history_cache_cost(before.level, after.level)
104
- ["From the next turn's start (a running turn keeps its own).", COST_NOTES[cost]].compact
105
- end
25
+ SPEC = Spec.new(
26
+ name: NAME, label: "reasoning history", usage: USAGE, cost_notes: COST_NOTES,
27
+ parse: lambda { |word|
28
+ level = ReasoningHistory.session_level(word)
29
+ [!level.nil? || word == ReasoningHistory::UNSET, level]
30
+ },
31
+ own: lambda(&:reasoning_history_override),
32
+ explained: ->(engine, own) { engine.reasoning_history_explained(native: true, own: own) },
33
+ set: ->(engine, own) { engine.reasoning_history_override = own },
34
+ cache_cost: ->(engine, from, to) { engine.reasoning_history_cache_cost(from, to) },
35
+ follows: "#{ReasoningHistory::ENV_NAME}, the model, its host, then turn",
36
+ extra_line: lambda { |engine|
37
+ chat = begin
38
+ engine.chat_model?
39
+ rescue StandardError
40
+ false
41
+ end
42
+ chat ? nil : NATIVE_NOTE
43
+ }
44
+ )
106
45
  end
107
46
  end
@@ -259,6 +259,8 @@ module Samagotchi
259
259
  self.interrupted_turn = interrupted_turn
260
260
  @turn_checkpoint = nil
261
261
  @archived = false
262
+ # #save's lock: one snapshot-and-write at a time (see #save).
263
+ @save_lock = Mutex.new
262
264
  end
263
265
 
264
266
  # A `delegate` child: its turns report to its parent (ChildReports). A
@@ -513,18 +515,30 @@ module Samagotchi
513
515
  end
514
516
 
515
517
  # Persist the session atomically. Updates +updated_at+ in place.
518
+ #
519
+ # One save at a time per Session: a worker saves its session from the
520
+ # loop/turn thread (TurnCheckpoint, Engine's turn end, WorkerInbound),
521
+ # from Bridge threads (QuestionDesk, commands run mid-turn) and from a
522
+ # signal's thread. AtomicFile keeps each write whole, but two
523
+ # unserialized saves could rename an older snapshot over a newer one (a
524
+ # lost update); under the lock the last rename is the latest snapshot.
525
+ # Never call it from a trap handler (Mutex#lock raises ThreadError
526
+ # there; WorkerSignals saves on a thread of its own) or from inside a
527
+ # save (the lock isn't re-entrant).
516
528
  def save(state_dir: self.class.default_state_dir)
517
- @updated_at = Time.now.iso8601(3)
518
- # Persist with current metadata version so new flag is written
519
- @metadata_version = METADATA_VERSION
520
- FileUtils.mkdir_p(state_dir)
529
+ @save_lock.synchronize do
530
+ @updated_at = Time.now.iso8601(3)
531
+ # Persist with current metadata version so new flag is written
532
+ @metadata_version = METADATA_VERSION
533
+ FileUtils.mkdir_p(state_dir)
521
534
 
522
- path = self.class.session_file(@id, state_dir: state_dir)
535
+ path = self.class.session_file(@id, state_dir: state_dir)
523
536
 
524
- # Auto-compute first_preview if not yet cached and messages contain a user entry.
525
- compute_first_preview!
537
+ # Auto-compute first_preview if not yet cached and messages contain a user entry.
538
+ compute_first_preview!
526
539
 
527
- AtomicFile.write(path, JSON.pretty_generate(to_h) + "\n")
540
+ AtomicFile.write(path, JSON.pretty_generate(to_h) + "\n")
541
+ end
528
542
  self
529
543
  end
530
544
 
@@ -852,6 +852,9 @@ module Samagotchi
852
852
 
853
853
  called_as = event.dig(:activity, :called_as)
854
854
  active[:called_as] = called_as.to_s if called_as
855
+ # Reached through call_tool: the reloaded row says so (not an alias).
856
+ via = event.dig(:activity, :via)
857
+ active[:via] = via.to_s if via
855
858
  # A grep that found nothing (ok): the reloaded web row says "no match".
856
859
  active[:no_match] = true if event.dig(:activity, :no_match)
857
860
  finish_tool_record(active, status.empty? ? "ok" : status, waited_ms: event[:waited_ms].to_i)
@@ -861,7 +864,7 @@ module Samagotchi
861
864
  # Caller holds the mutex.
862
865
  def finish_tool_record(active, status, waited_ms: 0)
863
866
  record = active.slice(
864
- :id, :turn_id, :iteration, :call_index, :tool, :called_as, :no_match, :started_at
867
+ :id, :turn_id, :iteration, :call_index, :tool, :called_as, :via, :no_match, :started_at
865
868
  ).merge(
866
869
  status: status,
867
870
  completed_at: now.iso8601(3),