samagotchi 0.2.0 → 0.4.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +198 -1
  3. data/README.md +56 -4
  4. data/bin/chi +118 -50
  5. data/docs/cli.md +184 -9
  6. data/docs/configuration.md +333 -47
  7. data/docs/desktop.md +45 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +208 -5
  10. data/docs/plugins.md +68 -2
  11. data/docs/releasing.md +23 -13
  12. data/docs/sessions.md +45 -17
  13. data/lib/samagotchi/answer_display.rb +95 -0
  14. data/lib/samagotchi/archive_store.rb +90 -0
  15. data/lib/samagotchi/bootstrap/config_writer.rb +342 -0
  16. data/lib/samagotchi/bootstrap/probe.rb +262 -0
  17. data/lib/samagotchi/bootstrap_command.rb +347 -0
  18. data/lib/samagotchi/bridge/pending_card.rb +89 -0
  19. data/lib/samagotchi/bridge/turn_accumulator.rb +15 -3
  20. data/lib/samagotchi/bridge.rb +13 -1
  21. data/lib/samagotchi/bridge_client.rb +6 -2
  22. data/lib/samagotchi/bundles/check-in/manifest.yml +10 -0
  23. data/lib/samagotchi/bundles/check-in/plugin.rb +244 -0
  24. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +531 -0
  25. data/lib/samagotchi/bundles/source-links/manifest.yml +14 -0
  26. data/lib/samagotchi/bundles/source-links/source_links.md +5 -0
  27. data/lib/samagotchi/bundles/system/config_modification_protocol.md +10 -6
  28. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  29. data/lib/samagotchi/bundles/system/manifest.yml +4 -4
  30. data/lib/samagotchi/bundles/system/self_map.md +8 -2
  31. data/lib/samagotchi/client.rb +81 -19
  32. data/lib/samagotchi/commands/registry.rb +8 -0
  33. data/lib/samagotchi/config.rb +252 -48
  34. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  35. data/lib/samagotchi/desktop/macos/ChiRunner.swift +17 -9
  36. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  37. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  38. data/lib/samagotchi/desktop/macos/Panel.swift +180 -25
  39. data/lib/samagotchi/desktop/macos.rb +59 -8
  40. data/lib/samagotchi/desktop_command.rb +6 -3
  41. data/lib/samagotchi/edit_preview.rb +82 -0
  42. data/lib/samagotchi/empty_answer_retry.rb +43 -0
  43. data/lib/samagotchi/engine.rb +434 -140
  44. data/lib/samagotchi/gem_update.rb +89 -0
  45. data/lib/samagotchi/guardrails/approval.rb +35 -4
  46. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  47. data/lib/samagotchi/guardrails/scratch_writes.rb +40 -0
  48. data/lib/samagotchi/guardrails.rb +1 -0
  49. data/lib/samagotchi/hooks/registry.rb +24 -5
  50. data/lib/samagotchi/host_registry.rb +9 -12
  51. data/lib/samagotchi/idle_client.rb +24 -15
  52. data/lib/samagotchi/idle_recap.rb +5 -1
  53. data/lib/samagotchi/idle_reminders.rb +2 -2
  54. data/lib/samagotchi/image_store.rb +10 -6
  55. data/lib/samagotchi/kernel_loop.rb +73 -94
  56. data/lib/samagotchi/live_versions.rb +59 -0
  57. data/lib/samagotchi/llm/api_key.rb +41 -0
  58. data/lib/samagotchi/llm/chat_loop.rb +132 -29
  59. data/lib/samagotchi/llm/errors.rb +41 -9
  60. data/lib/samagotchi/llm/http.rb +57 -17
  61. data/lib/samagotchi/llm/openai_chat.rb +17 -30
  62. data/lib/samagotchi/log_subscriber.rb +18 -3
  63. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  64. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  65. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  66. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  67. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  68. data/lib/samagotchi/model_profile.rb +24 -1
  69. data/lib/samagotchi/plugin/context.rb +22 -1
  70. data/lib/samagotchi/plugin/sessions.rb +3 -1
  71. data/lib/samagotchi/prompt.rb +4 -2
  72. data/lib/samagotchi/reminder_store.rb +1 -9
  73. data/lib/samagotchi/reply_wait.rb +126 -0
  74. data/lib/samagotchi/sampling_settings.rb +58 -0
  75. data/lib/samagotchi/self_report.rb +18 -3
  76. data/lib/samagotchi/send_command.rb +252 -11
  77. data/lib/samagotchi/session.rb +52 -11
  78. data/lib/samagotchi/session_archive_command.rb +107 -0
  79. data/lib/samagotchi/session_commands.rb +46 -7
  80. data/lib/samagotchi/session_manager.rb +115 -25
  81. data/lib/samagotchi/session_metrics.rb +222 -106
  82. data/lib/samagotchi/steer.rb +72 -0
  83. data/lib/samagotchi/terminal_ui/attached_loop.rb +57 -28
  84. data/lib/samagotchi/terminal_ui/event_renderer.rb +21 -11
  85. data/lib/samagotchi/terminal_ui/formatting.rb +40 -8
  86. data/lib/samagotchi/terminal_ui/input_support.rb +7 -19
  87. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  88. data/lib/samagotchi/terminal_ui.rb +134 -247
  89. data/lib/samagotchi/text_diff.rb +181 -0
  90. data/lib/samagotchi/thinking.rb +115 -0
  91. data/lib/samagotchi/tool_activity.rb +3 -1
  92. data/lib/samagotchi/tool_runner.rb +34 -1
  93. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  94. data/lib/samagotchi/tools/builtins.rb +15 -4
  95. data/lib/samagotchi/tools/delegate_wait.rb +26 -69
  96. data/lib/samagotchi/tools/edit.rb +23 -9
  97. data/lib/samagotchi/tools/execute.rb +52 -14
  98. data/lib/samagotchi/tools/task_runtime.rb +19 -0
  99. data/lib/samagotchi/tools/task_wait.rb +27 -3
  100. data/lib/samagotchi/tools/write.rb +4 -0
  101. data/lib/samagotchi/turn_flow.rb +12 -2
  102. data/lib/samagotchi/turn_note.rb +60 -6
  103. data/lib/samagotchi/update_command.rb +308 -0
  104. data/lib/samagotchi/update_hint.rb +59 -0
  105. data/lib/samagotchi/version.rb +1 -1
  106. data/lib/samagotchi/vision_support.rb +7 -9
  107. data/lib/samagotchi/web/app.rb +91 -7
  108. data/lib/samagotchi/web/message_parts.rb +8 -3
  109. data/lib/samagotchi/web/public/activity.js +13 -1
  110. data/lib/samagotchi/web/public/annotate_presets.js +26 -0
  111. data/lib/samagotchi/web/public/annotations.js +13 -0
  112. data/lib/samagotchi/web/public/app.js +472 -111
  113. data/lib/samagotchi/web/public/card.js +5 -3
  114. data/lib/samagotchi/web/public/chat_view.js +13 -1
  115. data/lib/samagotchi/web/public/copy.js +20 -4
  116. data/lib/samagotchi/web/public/ctx.js +15 -0
  117. data/lib/samagotchi/web/public/data.js +23 -6
  118. data/lib/samagotchi/web/public/diff_view.js +58 -0
  119. data/lib/samagotchi/web/public/format.js +9 -0
  120. data/lib/samagotchi/web/public/index.html +60 -3
  121. data/lib/samagotchi/web/public/notify.js +175 -0
  122. data/lib/samagotchi/web/public/question_card.js +5 -2
  123. data/lib/samagotchi/web/public/sessions_list.js +7 -0
  124. data/lib/samagotchi/web/public/timing.js +39 -14
  125. data/lib/samagotchi/web/public/turn_events.js +75 -5
  126. data/lib/samagotchi/web/public/turn_view.js +49 -8
  127. data/lib/samagotchi/web/server.rb +8 -4
  128. data/lib/samagotchi/web/session_hub.rb +2 -1
  129. data/lib/samagotchi/web/session_summary.rb +24 -1
  130. data/lib/samagotchi/worker.rb +16 -4
  131. metadata +31 -1
@@ -19,7 +19,8 @@ module Samagotchi
19
19
  # (#kind): the UIs print one line per kind, and #retryable? says whether
20
20
  # asking again could help.
21
21
  #
22
- # ConnectionError no answer (refused, reset, timeout); RetryExhausted
22
+ # ConnectionError no answer (refused, reset, timeout); RetryExhausted,
23
+ # ConnectionRefused
23
24
  # RateLimited 429, with retry_after when the server says
24
25
  # ServerError 5xx or a server's error event mid-stream
25
26
  # AuthError 401/403, or an API key variable that is not set
@@ -51,14 +52,14 @@ module Samagotchi
51
52
  # One line for the UIs, e.g. "auth failed for host fw: set FW_KEY".
52
53
  def summary = "error from host #{host}: #{detail}"
53
54
 
54
- private
55
-
56
- def default_retryable? = false
57
-
58
55
  # The message without its leading "host: ".
59
56
  def detail
60
57
  host && message.start_with?("#{host}: ") ? message.delete_prefix("#{host}: ") : message
61
58
  end
59
+
60
+ private
61
+
62
+ def default_retryable? = false
62
63
  end
63
64
 
64
65
  class ConnectionError < ProviderError
@@ -90,20 +91,29 @@ module Samagotchi
90
91
  end
91
92
 
92
93
  class AuthError < ProviderError
94
+ # What to try (LLM::ApiKey), added to the summary.
95
+ attr_accessor :hint
96
+
93
97
  def kind = :auth
94
98
 
95
- def summary = "auth failed for host #{host}: #{detail}"
99
+ def summary = "auth failed for host #{host}: #{detail}#{"; #{hint}" if hint}"
96
100
  end
97
101
 
98
102
  class BadRequest < ProviderError
99
103
  TOOLS_HINT = "this model can't use tools, and chi needs them: pick another model (/model) or host"
100
104
 
101
- def initialize(message = nil, context_overflow: false, tools_unsupported: false, **options)
105
+ # @param hint [String, nil] what to try, added to the summary
106
+ def initialize(message = nil, context_overflow: false, tools_unsupported: false, reasoning_refused: false, hint: nil,
107
+ **options)
102
108
  @context_overflow = context_overflow
103
109
  @tools_unsupported = tools_unsupported
110
+ @reasoning_refused = reasoning_refused
111
+ @hint = hint
104
112
  super(message, **options)
105
113
  end
106
114
 
115
+ attr_reader :hint
116
+
107
117
  # The prompt is larger than the model's context window.
108
118
  def context_overflow? = @context_overflow
109
119
 
@@ -111,6 +121,10 @@ module Samagotchi
111
121
  # and chi always sends them.
112
122
  def tools_unsupported? = @tools_unsupported
113
123
 
124
+ # A 400 about reasoning or thinking: the request's thinking fields may
125
+ # be what the host refuses (gpt-oss: "Reasoning is mandatory").
126
+ def reasoning_refused? = @reasoning_refused
127
+
114
128
  def kind = :bad_request
115
129
 
116
130
  def summary
@@ -119,7 +133,7 @@ module Samagotchi
119
133
  elsif tools_unsupported?
120
134
  "host #{host} rejected the request: #{tools_detail}; #{TOOLS_HINT}"
121
135
  else
122
- "host #{host} rejected the request: #{detail}"
136
+ "host #{host} rejected the request: #{detail}#{"; #{hint}" if hint}"
123
137
  end
124
138
  end
125
139
 
@@ -174,6 +188,19 @@ module Samagotchi
174
188
  def summary = "network error after #{attempts} attempts (host #{host}: #{last_error.class})"
175
189
  end
176
190
 
191
+ # A refused connection: nothing listens at the host's address. Not
192
+ # retried (a server that isn't running rarely starts within the backoff).
193
+ class ConnectionRefused < ConnectionError
194
+ attr_reader :address
195
+
196
+ def initialize(host:, address: nil, attempts: 1)
197
+ @address = address
198
+ super("#{host}: connection refused#{" at #{address}" if address}", host: host, retryable: false, attempts: attempts)
199
+ end
200
+
201
+ def summary = "can't reach host #{host}#{" at #{address}" if address} (connection refused) — is the server running?"
202
+ end
203
+
177
204
  # A stream that showed nothing (no text, reasoning or tool call) within
178
205
  # the host's first-token limit. A queued request can stay open for many
179
206
  # minutes on keep-alive comments alone, which reset the read timeout.
@@ -209,6 +236,8 @@ module Samagotchi
209
236
  TOOLS_UNSUPPORTED_RE = /support tool use|(does not|doesn't) support tools|tool choice requires --enable-auto-tool-choice/i
210
237
  # A model or server that can't take images (see VisionUnsupported).
211
238
  VISION_UNSUPPORTED_RE = /support image input|image input is not supported|mmproj/i
239
+ # A 400 about the thinking fields chi sent (Thinking.chat_fields).
240
+ REASONING_REFUSED_RE = /reasoning|thinking/i
212
241
  RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
213
242
 
214
243
  module_function
@@ -230,7 +259,10 @@ module Samagotchi
230
259
  when 401, 403 then AuthError.new(text, **options)
231
260
  when 429 then RateLimited.new(text, retry_after: parse_retry_after(retry_after), **options)
232
261
  when 408 then ServerError.new(text, retryable: true, **options)
233
- when 400..499 then BadRequest.new(text, tools_unsupported: TOOLS_UNSUPPORTED_RE.match?(message), **options)
262
+ when 400..499
263
+ tools = TOOLS_UNSUPPORTED_RE.match?(message)
264
+ BadRequest.new(text, tools_unsupported: tools,
265
+ reasoning_refused: status == 400 && !tools && REASONING_REFUSED_RE.match?(message), **options)
234
266
  when 500..599
235
267
  ServerError.new(text, retryable: RETRYABLE_SERVER_STATUSES.include?(status),
236
268
  retry_after: parse_retry_after(retry_after), **options)
@@ -6,6 +6,7 @@ require_relative "../config"
6
6
  require_relative "../version"
7
7
  require_relative "../log"
8
8
  require_relative "errors"
9
+ require_relative "api_key"
9
10
 
10
11
  module Samagotchi
11
12
  module LLM
@@ -101,8 +102,12 @@ module Samagotchi
101
102
  # @param sleeper [#call] waits the given seconds (specs pass a no-op)
102
103
  # @param first_token_timeout [Numeric, nil] seconds a stream may take to
103
104
  # show something (see #stream_lines); nil: no limit
104
- def initialize(label:, open_timeout:, read_timeout:, retry_policy: nil, sleeper: nil, first_token_timeout: nil)
105
+ # @param api_key [ApiKey, nil] sent on every request; nil: no
106
+ # Authorization header (a local server)
107
+ def initialize(label:, open_timeout:, read_timeout:, retry_policy: nil, sleeper: nil, first_token_timeout: nil,
108
+ api_key: nil)
105
109
  @label = label
110
+ @api_key = api_key
106
111
  @open_timeout = open_timeout
107
112
  @read_timeout = read_timeout
108
113
  @first_token_timeout = first_token_timeout&.positive? ? first_token_timeout : nil
@@ -176,15 +181,18 @@ module Samagotchi
176
181
  private :stream_attempt
177
182
 
178
183
  # Send +request+ and return the response with its body read.
179
- # @param retries [Boolean] false: one attempt, network errors raised as is
184
+ # @param retries [Boolean] false: one attempt, network errors raised as
185
+ # is (Net::HTTP's own silent retry of an idempotent GET included)
180
186
  # @param check_status [Boolean] false: return an error response instead
181
187
  # of raising its ProviderError
188
+ # @raise [RequestCancelled] when +cancel_controller+ cancels (with or
189
+ # without retries)
182
190
  def fetch(uri, request, retries: true, check_status: true, open_timeout: nil, read_timeout: nil,
183
191
  cancel_controller: nil, log_fields: {})
184
192
  identify(request)
185
193
  current = new_attempt_state(uri, request, log_fields, stream: false)
186
194
  attempt = lambda do |state|
187
- start(uri, open_timeout: open_timeout, read_timeout: read_timeout) do |http|
195
+ start(uri, open_timeout: open_timeout, read_timeout: read_timeout, max_retries: retries ? nil : 0) do |http|
188
196
  state[:http] = http
189
197
  http.request(request).tap do |response|
190
198
  state[:status] = response.code.to_i
@@ -193,20 +201,30 @@ module Samagotchi
193
201
  end
194
202
  end
195
203
  logged(current) do
196
- next attempt.call(current.merge!(attempts: 1)) unless retries
204
+ next with_retries(cancel_controller, nil, nil, current, &attempt) if retries
197
205
 
198
- with_retries(cancel_controller, nil, nil, current, &attempt)
206
+ cancellable(cancel_controller, current) do
207
+ attempt.call(current.merge!(attempts: 1))
208
+ rescue StandardError => e
209
+ raise RequestCancelled.new(cancel_controller.reason) if !e.is_a?(RequestCancelled) && cancel_controller&.cancelled?
210
+
211
+ raise
212
+ end
199
213
  end
200
214
  end
201
215
 
202
216
  private
203
217
 
218
+ # User-Agent, and the host's key when it has one (AuthError, before
219
+ # any request, when its variable is not set).
204
220
  def identify(request)
205
221
  request["User-Agent"] = Samagotchi::USER_AGENT
222
+ @api_key&.authorize(request)
206
223
  end
207
224
 
208
- def start(uri, open_timeout: nil, read_timeout: nil, &block)
225
+ def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &block)
209
226
  options = { open_timeout: open_timeout || @open_timeout, read_timeout: read_timeout || @read_timeout }
227
+ options[:max_retries] = max_retries if max_retries
210
228
  options[:use_ssl] = true if uri.scheme == "https"
211
229
  Net::HTTP.start(uri.host, uri.port, **options, &block)
212
230
  end
@@ -215,10 +233,29 @@ module Samagotchi
215
233
  # errors. The block gets a hash to put the attempt's Net::HTTP in, so a
216
234
  # cancel can reach its socket.
217
235
  def with_retries(cancel_controller, on_retry, on_network_error, current = { mutex: Mutex.new })
236
+ cancellable(cancel_controller, current) do
237
+ retrying(cancel_controller, on_retry, on_network_error, current) { |state| yield(state) }
238
+ end
239
+ end
240
+
241
+ # Runs the block with +cancel_controller+ able to abort it: a cancel
242
+ # closes the socket in current[:http] (or raises RequestCancelled in
243
+ # this thread while there is none).
244
+ def cancellable(cancel_controller, current)
218
245
  requesting_thread = Thread.current
219
246
  listener_id = cancel_controller&.on_cancel { |reason| abort_request(current, requesting_thread, reason) }
220
247
  raise RequestCancelled.new(cancel_controller.reason) if cancel_controller&.cancelled?
221
248
 
249
+ yield
250
+ ensure
251
+ # A cancel can call the listener after remove_listener returned (the
252
+ # controller calls listeners outside its lock); :done keeps it from
253
+ # raising into this thread once the request is over.
254
+ current[:mutex].synchronize { current[:done] = true }
255
+ cancel_controller&.remove_listener(listener_id)
256
+ end
257
+
258
+ def retrying(cancel_controller, on_retry, on_network_error, current)
222
259
  attempts = 0
223
260
  loop do
224
261
  attempts += 1
@@ -243,6 +280,12 @@ module Samagotchi
243
280
  raise unless self.class.network_error?(e)
244
281
 
245
282
  on_network_error&.call(e)
283
+ # Nothing is listening: asking again rarely helps, so it fails
284
+ # at once and says what to check.
285
+ if e.is_a?(Errno::ECONNREFUSED)
286
+ raise ConnectionRefused.new(host: @label, address: current[:address], attempts: attempts)
287
+ end
288
+
246
289
  delay = current[:streamed] ? nil : @retry_policy.delay_for(attempts)
247
290
  raise RetryExhausted.new(attempts: attempts, last_error: e, label: @label) if delay.nil?
248
291
 
@@ -251,12 +294,6 @@ module Samagotchi
251
294
  current.delete(:http)
252
295
  end
253
296
  end
254
- ensure
255
- # A cancel can call the listener after remove_listener returned (the
256
- # controller calls listeners outside its lock); :done keeps it from
257
- # raising into this thread once the request is over.
258
- current[:mutex].synchronize { current[:done] = true }
259
- cancel_controller&.remove_listener(listener_id)
260
297
  end
261
298
 
262
299
  # Runs one attempt (the block) under the first-token limit: a watchdog
@@ -302,7 +339,7 @@ module Samagotchi
302
339
  # closes, status, timings) and what its log line says.
303
340
  def new_attempt_state(uri, request, log_fields, stream:)
304
341
  purpose = log_fields[:purpose]&.to_s
305
- { mutex: Mutex.new, started_at: monotonic_now, stream: stream,
342
+ { mutex: Mutex.new, started_at: monotonic_now, stream: stream, address: "#{uri.host}:#{uri.port}",
306
343
  level: LOGGED_PURPOSES.include?(purpose) ? :info : :debug,
307
344
  log: { host: @label, method: request.method, url: log_url(uri),
308
345
  **log_fields.compact } }
@@ -326,7 +363,7 @@ module Samagotchi
326
363
  ms: elapsed_ms(current), attempts: retried(current))
327
364
  result
328
365
  rescue RequestCancelled => e
329
- Log.info(:http, "cancelled", **log_base(current), ms: elapsed_ms(current), reason: e.reason&.to_s)
366
+ Log.public_send(current[:level], :http, "cancelled", **log_base(current), ms: elapsed_ms(current), reason: e.reason&.to_s)
330
367
  raise
331
368
  rescue StandardError => e
332
369
  failure_level = current[:level] == :info ? :error : :debug
@@ -359,7 +396,8 @@ module Samagotchi
359
396
  status: error.is_a?(ProviderError) ? error.status : nil, error: error.class.name,
360
397
  msg: error.message.to_s[0, 300])
361
398
  on_retry&.call(attempt: attempts, max_retries: @retry_policy.max, next_delay: delay,
362
- error_class: error.class.name, error_message: error.message)
399
+ error_class: error.class.name, error_message: error.message,
400
+ status: error.is_a?(ProviderError) ? error.status : nil)
363
401
  wait(delay, cancel_controller)
364
402
  end
365
403
 
@@ -367,8 +405,10 @@ module Samagotchi
367
405
  status = response.code.to_i
368
406
  return if status.between?(200, 299)
369
407
 
370
- raise ProviderErrors.from_response(status: status, body: response.body.to_s, host: @label,
371
- retry_after: response["Retry-After"])
408
+ error = ProviderErrors.from_response(status: status, body: response.body.to_s, host: @label,
409
+ retry_after: response["Retry-After"])
410
+ error.hint ||= @api_key ? @api_key.hint : ApiKey.missing_hint(@label) if error.is_a?(AuthError)
411
+ raise error
372
412
  end
373
413
 
374
414
  def abort_request(current, requesting_thread, reason)
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "uri"
5
5
  require_relative "../config"
6
+ require_relative "../sampling_settings"
6
7
  require_relative "errors"
7
8
  require_relative "http"
8
9
  require_relative "usage"
@@ -89,13 +90,13 @@ module Samagotchi
89
90
  @host_name = host_name.to_s
90
91
  @api_key_env = api_key_env
91
92
  @stream = stream
92
- @env = env
93
93
  @models_ttl = models_ttl
94
94
  @models_mutex = Mutex.new
95
95
  open_timeout, read_timeout = timeouts(timeout)
96
96
  policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
97
97
  @http = HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout,
98
- retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout)
98
+ retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout,
99
+ api_key: ApiKey.for(api_key_env, host: @host_name, env: env))
99
100
  end
100
101
 
101
102
  # @param messages [Array<Hash>] wire messages (role, content, tool_calls,
@@ -111,9 +112,9 @@ module Samagotchi
111
112
  # @return [ChatResponse]
112
113
  def chat(messages:, model:, tools: [], cancel_controller: nil, on_delta: nil, on_retry: nil, options: {},
113
114
  session_id: nil)
114
- request = post_request("#{@base_url}/chat/completions", request_body(messages, tools, model, options),
115
- session_id: session_id)
116
- log_fields = { model: model, purpose: @purpose }
115
+ body = request_body(messages, tools, model, options)
116
+ request = post_request("#{@base_url}/chat/completions", body, session_id: session_id)
117
+ log_fields = { model: model, purpose: @purpose, sampling: sampling_summary(body) }
117
118
  return chat_once(request, cancel_controller, log_fields) unless @stream
118
119
 
119
120
  assembly = Assembly.new
@@ -183,20 +184,13 @@ module Samagotchi
183
184
  private
184
185
 
185
186
  def timeouts(timeout)
186
- open_timeout = config_integer("server.open_timeout", 10)
187
- read_timeout = config_integer("server.read_timeout", 600)
187
+ open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout")
188
+ read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout")
188
189
  return [open_timeout, read_timeout] unless timeout
189
190
 
190
191
  [[open_timeout, timeout].min, timeout]
191
192
  end
192
193
 
193
- def config_integer(key, default)
194
- value = Samagotchi::Config.get(key).to_i
195
- value.positive? ? value : default
196
- rescue StandardError
197
- default
198
- end
199
-
200
194
  def request_body(messages, tools, model, options)
201
195
  body = {
202
196
  model: model,
@@ -209,7 +203,14 @@ module Samagotchi
209
203
  body[:tools] = tools
210
204
  body[:tool_choice] = "auto"
211
205
  end
212
- body.merge(options || {})
206
+ # A configured value replaces chi's own (temperature); nil drops it.
207
+ body.merge(options || {}).compact
208
+ end
209
+
210
+ # The body's fields beyond the conversation and the stream, for the
211
+ # log line: "temperature=0.6 presence_penalty=1.5".
212
+ def sampling_summary(body)
213
+ SamplingSettings.log_text(body.except(:model, :messages, :stream, :stream_options, :tools, :tool_choice))
213
214
  end
214
215
 
215
216
  # A String stays a String; an Array of parts passes as given.
@@ -278,25 +279,11 @@ module Samagotchi
278
279
  Net::HTTP::Post.new(uri).tap do |request|
279
280
  request["Content-Type"] = "application/json"
280
281
  request["Session-Id"] = session_id.to_s unless session_id.to_s.empty?
281
- authorize(request)
282
282
  request.body = JSON.generate(body)
283
283
  end
284
284
  end
285
285
 
286
- def get_request(uri)
287
- Net::HTTP::Get.new(uri).tap { |request| authorize(request) }
288
- end
289
-
290
- def authorize(request)
291
- return unless @api_key_env
292
-
293
- key = @env[@api_key_env].to_s
294
- if key.strip.empty?
295
- raise AuthError.new("#{@host_name}: set #{@api_key_env} (the API key for host #{@host_name})",
296
- host: @host_name)
297
- end
298
- request["Authorization"] = "Bearer #{key}"
299
- end
286
+ def get_request(uri) = Net::HTTP::Get.new(uri)
300
287
 
301
288
  def model_entries(body)
302
289
  return [] unless body.is_a?(Hash)
@@ -27,8 +27,8 @@ module Samagotchi
27
27
  question_requested: [],
28
28
  question_answered: %i[id],
29
29
  question_cancelled: %i[id reason],
30
- pending_input_merged: %i[iteration count],
31
- generation_cancelled: %i[iteration]
30
+ generation_cancelled: %i[iteration],
31
+ empty_answer_retry: %i[iteration attempt of finish_reason thinking_chars]
32
32
  }.freeze
33
33
 
34
34
  # @param session_id [#call] the session the events are about (the
@@ -71,6 +71,15 @@ module Samagotchi
71
71
  exhausted: summary[:exhausted] || nil, **origin(event))
72
72
  end
73
73
 
74
+ # A plugin's steers by count and source, never their text.
75
+ def on_pending_input_merged(event)
76
+ steers = Array(event[:steers])
77
+ log(:info, :pending_input_merged, **event.slice(:iteration, :count),
78
+ steers: steers.empty? ? nil : steers.size,
79
+ steer_sources: steers.empty? ? nil : steers.map { |s| s[:source] }.uniq.join(","),
80
+ **origin(event))
81
+ end
82
+
74
83
  def on_turn_canceled(event)
75
84
  log(:info, :turn_canceled, ms: since(@turn_started_at), reason: event[:cancellation_reason], **origin(event))
76
85
  end
@@ -92,7 +101,7 @@ module Samagotchi
92
101
  ms: since(@generation_started_at.delete(event[:iteration])),
93
102
  served_model: event[:served_model], requested_model: event[:requested_model],
94
103
  content_length: event[:content_length],
95
- thinking_chars: event[:thinking_chars])
104
+ thinking_chars: event[:thinking_chars], finish_reason: event[:finish_reason])
96
105
  end
97
106
 
98
107
  def on_generation_retrying(event)
@@ -125,6 +134,12 @@ module Samagotchi
125
134
  log(level, :hook_notice, hook: event[:hook], msg: event[:text].to_s[0, 300])
126
135
  end
127
136
 
137
+ # after_turn hooks presented the answer (AnswerDisplay): its size only
138
+ # (none for the display: nil that says nothing was presented).
139
+ def on_answer_display(event)
140
+ log(:info, :answer_display, chars: event[:display].to_s.length) if event[:display]
141
+ end
142
+
128
143
  # A card shown to the user (Engine#show_card): whose, which, and its
129
144
  # title (the body is the plugin's text; not logged).
130
145
  def on_card(event)
@@ -295,79 +295,81 @@ module Samagotchi
295
295
 
296
296
  warn_missing_needs(manifest) if manifest
297
297
 
298
- # Write provenance (only if we had a manifest and not dry_run).
298
+ # Write provenance (only if we had a manifest and not dry_run). An
299
+ # upgrade with conflicts writes it too: the new version, the plugin's
300
+ # and hooks' new shas, and the old base for each conflicted file,
301
+ # marked conflict (chi bundle status shows it). Skipping it left the
302
+ # replaced plugin failing its sha check.
299
303
  if manifest && !@dry_run
300
- skip_provenance = @upgrade && @conflicts.any? && !@force
301
- unless skip_provenance
302
- provenance_files = {}
303
- if @upgrade && existing_provenance
304
- all_files_in_bundle.each do |file_key|
305
- target_path = File.join(target_dir, file_key)
306
- next unless File.exist?(target_path)
307
- status = @results[file_key] ? @results[file_key][:status].to_s : nil
308
- if %w[kept noop conflict kept_pruned].include?(status)
309
- # Keep old base snapshot — do not overwrite with edited current
310
- # Use base snapshot as source if it exists to preserve old checksum
311
- base_path = provenance.base_path(file_key)
312
- if File.exist?(base_path)
313
- # Keep existing provenance entry by re-using base content
314
- # We still need to include it to prevent pruning, but with base content
315
- provenance_files[file_key] = base_path
316
- else
317
- provenance_files[file_key] = target_path
318
- end
304
+ provenance_files = {}
305
+ if @upgrade && existing_provenance
306
+ all_files_in_bundle.each do |file_key|
307
+ target_path = File.join(target_dir, file_key)
308
+ next unless File.exist?(target_path)
309
+ status = @results[file_key] ? @results[file_key][:status].to_s : nil
310
+ if %w[kept noop conflict kept_pruned].include?(status)
311
+ # Keep old base snapshot — do not overwrite with edited current
312
+ # Use base snapshot as source if it exists to preserve old checksum
313
+ base_path = provenance.base_path(file_key)
314
+ if File.exist?(base_path)
315
+ # Keep existing provenance entry by re-using base content
316
+ # We still need to include it to prevent pruning, but with base content
317
+ provenance_files[file_key] = base_path
319
318
  else
320
- # installed, updated, skipped — use current target (which is incoming for updated)
321
319
  provenance_files[file_key] = target_path
322
320
  end
321
+ else
322
+ # installed, updated, skipped — use current target (which is incoming for updated)
323
+ provenance_files[file_key] = target_path
323
324
  end
324
- # Handle pruned but kept files (bundle removed file but local kept)
325
- if existing_provenance[:files]
326
- existing_provenance[:files].keys.each do |old_key|
327
- old_key_str = old_key.to_s
328
- next if all_files_in_bundle.include?(old_key_str)
329
- # If it was kept_pruned, include it with base content to prevent pruning
330
- if @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
331
- base_path = provenance.base_path(old_key_str)
332
- target_path = File.join(target_dir, old_key_str)
333
- # Use base if exists to keep old checksum, else target
334
- src = File.exist?(base_path) ? base_path : target_path
335
- provenance_files[old_key_str] = src if File.exist?(src)
336
- end
325
+ end
326
+ # Handle pruned but kept files (bundle removed file but local kept)
327
+ if existing_provenance[:files]
328
+ existing_provenance[:files].keys.each do |old_key|
329
+ old_key_str = old_key.to_s
330
+ next if all_files_in_bundle.include?(old_key_str)
331
+ # If it was kept_pruned, include it with base content to prevent pruning
332
+ if @results[old_key_str] && @results[old_key_str][:status].to_s == "kept_pruned"
333
+ base_path = provenance.base_path(old_key_str)
334
+ target_path = File.join(target_dir, old_key_str)
335
+ # Use base if exists to keep old checksum, else target
336
+ src = File.exist?(base_path) ? base_path : target_path
337
+ provenance_files[old_key_str] = src if File.exist?(src)
337
338
  end
338
339
  end
339
- else
340
- all_files_in_bundle.each do |file_key|
341
- target_path = File.join(target_dir, file_key)
342
- provenance_files[file_key] = target_path if File.exist?(target_path)
343
- end
344
340
  end
345
- # Build hooks metadata for provenance
346
- hooks_for_provenance = {}
347
- if manifest && manifest.hooks && !manifest.hooks.empty?
348
- hooks_for_provenance = manifest.hooks
349
- elsif hooks_files_for_provenance.any?
350
- hooks_files_for_provenance.each do |basename, path|
351
- next unless File.exist?(path)
352
- sha = Digest::SHA256.hexdigest(File.read(path))
353
- hooks_for_provenance[basename] = { "sha256" => "sha256:#{sha}", "event" => "", "on_error" => "skip", "priority" => 100 }
354
- end
341
+ else
342
+ all_files_in_bundle.each do |file_key|
343
+ target_path = File.join(target_dir, file_key)
344
+ provenance_files[file_key] = target_path if File.exist?(target_path)
345
+ end
346
+ end
347
+ # Build hooks metadata for provenance
348
+ hooks_for_provenance = {}
349
+ if manifest && manifest.hooks && !manifest.hooks.empty?
350
+ hooks_for_provenance = manifest.hooks
351
+ elsif hooks_files_for_provenance.any?
352
+ hooks_files_for_provenance.each do |basename, path|
353
+ next unless File.exist?(path)
354
+ sha = Digest::SHA256.hexdigest(File.read(path))
355
+ hooks_for_provenance[basename] = { "sha256" => "sha256:#{sha}", "event" => "", "on_error" => "skip", "priority" => 100 }
355
356
  end
356
- Provenance.new(name: @name).write(
357
- files: provenance_files,
358
- scope: target_scope,
359
- version: manifest.version,
360
- source_path: @source,
361
- hooks: hooks_for_provenance,
362
- trust_level: manifest.respond_to?(:trust_level) ? manifest.trust_level : nil,
363
- source_commit: source_commit,
364
- hooks_files: hooks_files_for_provenance,
365
- guardrails_files: guardrail_files_for_provenance,
366
- plugin_file: plugin_file_for_provenance,
367
- requires_chi: manifest.requires_chi,
368
- needs: manifest.needs
369
- )
370
357
  end
358
+ Provenance.new(name: @name).write(
359
+ files: provenance_files,
360
+ scope: target_scope,
361
+ version: manifest.version,
362
+ source_path: @source,
363
+ hooks: hooks_for_provenance,
364
+ trust_level: manifest.respond_to?(:trust_level) ? manifest.trust_level : nil,
365
+ source_commit: source_commit,
366
+ hooks_files: hooks_files_for_provenance,
367
+ guardrails_files: guardrail_files_for_provenance,
368
+ plugin_file: plugin_file_for_provenance,
369
+ requires_chi: manifest.requires_chi,
370
+ needs: manifest.needs,
371
+ conflicts: @conflicts.keys
372
+ )
371
373
  end
372
374
 
373
375
  # Detect placeholders.