samagotchi 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +162 -1
- data/README.md +29 -2
- data/bin/chi +60 -69
- data/docs/cli.md +211 -77
- data/docs/configuration.md +118 -21
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +89 -7
- data/docs/memory.md +40 -0
- data/docs/plugins.md +50 -0
- data/docs/releasing.md +15 -12
- data/docs/sessions.md +20 -18
- data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
- data/lib/samagotchi/bridge/sse_writer.rb +0 -3
- data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
- data/lib/samagotchi/bridge.rb +20 -12
- data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
- data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
- data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
- data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
- data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
- data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/identity.md +5 -0
- data/lib/samagotchi/bundles/system/manifest.yml +6 -6
- data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
- data/lib/samagotchi/bundles/system/self_map.md +2 -1
- data/lib/samagotchi/client.rb +25 -26
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +97 -113
- data/lib/samagotchi/desktop/macos/App.swift +12 -8
- data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
- data/lib/samagotchi/desktop/macos/Images.swift +113 -0
- data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
- data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
- data/lib/samagotchi/desktop/macos.rb +59 -8
- data/lib/samagotchi/desktop_command.rb +6 -3
- data/lib/samagotchi/edit_preview.rb +82 -0
- data/lib/samagotchi/engine.rb +236 -443
- data/lib/samagotchi/gem_update.rb +89 -0
- data/lib/samagotchi/guardrails/approval.rb +26 -4
- data/lib/samagotchi/guardrails/load_failures.rb +9 -3
- data/lib/samagotchi/host_registry.rb +8 -12
- data/lib/samagotchi/idle_client.rb +24 -15
- data/lib/samagotchi/idle_reminders.rb +2 -2
- data/lib/samagotchi/image_store.rb +10 -6
- data/lib/samagotchi/kernel_loop.rb +59 -123
- data/lib/samagotchi/live_versions.rb +65 -0
- data/lib/samagotchi/llm/api_key.rb +41 -0
- data/lib/samagotchi/llm/chat_loop.rb +77 -13
- data/lib/samagotchi/llm/errors.rb +38 -7
- data/lib/samagotchi/llm/http.rb +19 -22
- data/lib/samagotchi/llm/openai_chat.rb +22 -26
- data/lib/samagotchi/memory_bundle/installer.rb +65 -63
- data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
- data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
- data/lib/samagotchi/memory_bundle/status.rb +4 -1
- data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
- data/lib/samagotchi/model_profile.rb +27 -10
- data/lib/samagotchi/note_command.rb +2 -1
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/reply_wait.rb +48 -4
- data/lib/samagotchi/self_report.rb +37 -5
- data/lib/samagotchi/send_command.rb +190 -17
- data/lib/samagotchi/session.rb +4 -2
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +19 -53
- data/lib/samagotchi/system_prompt.rb +403 -0
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
- data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
- data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
- data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
- data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
- data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
- data/lib/samagotchi/terminal_ui/surface.rb +1 -1
- data/lib/samagotchi/terminal_ui.rb +142 -923
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +126 -0
- data/lib/samagotchi/tool_activity.rb +52 -2
- data/lib/samagotchi/tool_runner.rb +37 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/execute.rb +3 -3
- data/lib/samagotchi/tools/output_guardrails.rb +8 -7
- data/lib/samagotchi/tools/read.rb +4 -4
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/update_command.rb +309 -0
- data/lib/samagotchi/update_hint.rb +59 -0
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/vision_support.rb +6 -4
- data/lib/samagotchi/web/app.rb +173 -38
- data/lib/samagotchi/web/lan.rb +99 -0
- data/lib/samagotchi/web/message_parts.rb +19 -10
- data/lib/samagotchi/web/public/activity.js +10 -0
- data/lib/samagotchi/web/public/app.js +135 -78
- data/lib/samagotchi/web/public/chat_view.js +8 -1
- data/lib/samagotchi/web/public/data.js +2 -0
- data/lib/samagotchi/web/public/diff_view.js +58 -0
- data/lib/samagotchi/web/public/index.html +185 -18
- data/lib/samagotchi/web/public/model_pick.js +136 -0
- data/lib/samagotchi/web/public/model_picker.js +224 -0
- data/lib/samagotchi/web/public/notify.js +10 -0
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/stage_model.js +110 -0
- data/lib/samagotchi/web/public/stage_view.js +580 -0
- data/lib/samagotchi/web/public/timing.js +6 -2
- data/lib/samagotchi/web/public/turn_events.js +38 -10
- data/lib/samagotchi/web/public/turn_model.js +11 -3
- data/lib/samagotchi/web/public/turn_view.js +76 -20
- data/lib/samagotchi/web/qr.rb +40 -0
- data/lib/samagotchi/web/server.rb +101 -11
- data/lib/samagotchi/web/token.rb +97 -0
- data/lib/samagotchi/worker.rb +5 -4
- metadata +38 -3
- data/lib/samagotchi/terminal_ui/legacy_surface.rb +0 -111
|
@@ -52,14 +52,14 @@ module Samagotchi
|
|
|
52
52
|
# One line for the UIs, e.g. "auth failed for host fw: set FW_KEY".
|
|
53
53
|
def summary = "error from host #{host}: #{detail}"
|
|
54
54
|
|
|
55
|
-
private
|
|
56
|
-
|
|
57
|
-
def default_retryable? = false
|
|
58
|
-
|
|
59
55
|
# The message without its leading "host: ".
|
|
60
56
|
def detail
|
|
61
57
|
host && message.start_with?("#{host}: ") ? message.delete_prefix("#{host}: ") : message
|
|
62
58
|
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def default_retryable? = false
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
class ConnectionError < ProviderError
|
|
@@ -91,18 +91,23 @@ module Samagotchi
|
|
|
91
91
|
end
|
|
92
92
|
|
|
93
93
|
class AuthError < ProviderError
|
|
94
|
+
# What to try (LLM::ApiKey), added to the summary.
|
|
95
|
+
attr_accessor :hint
|
|
96
|
+
|
|
94
97
|
def kind = :auth
|
|
95
98
|
|
|
96
|
-
def summary = "auth failed for host #{host}: #{detail}"
|
|
99
|
+
def summary = "auth failed for host #{host}: #{detail}#{"; #{hint}" if hint}"
|
|
97
100
|
end
|
|
98
101
|
|
|
99
102
|
class BadRequest < ProviderError
|
|
100
103
|
TOOLS_HINT = "this model can't use tools, and chi needs them: pick another model (/model) or host"
|
|
101
104
|
|
|
102
105
|
# @param hint [String, nil] what to try, added to the summary
|
|
103
|
-
def initialize(message = nil, context_overflow: false, tools_unsupported: false, hint: nil,
|
|
106
|
+
def initialize(message = nil, context_overflow: false, tools_unsupported: false, reasoning_refused: false, hint: nil,
|
|
107
|
+
**options)
|
|
104
108
|
@context_overflow = context_overflow
|
|
105
109
|
@tools_unsupported = tools_unsupported
|
|
110
|
+
@reasoning_refused = reasoning_refused
|
|
106
111
|
@hint = hint
|
|
107
112
|
super(message, **options)
|
|
108
113
|
end
|
|
@@ -116,6 +121,10 @@ module Samagotchi
|
|
|
116
121
|
# and chi always sends them.
|
|
117
122
|
def tools_unsupported? = @tools_unsupported
|
|
118
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
|
+
|
|
119
128
|
def kind = :bad_request
|
|
120
129
|
|
|
121
130
|
def summary
|
|
@@ -227,6 +236,8 @@ module Samagotchi
|
|
|
227
236
|
TOOLS_UNSUPPORTED_RE = /support tool use|(does not|doesn't) support tools|tool choice requires --enable-auto-tool-choice/i
|
|
228
237
|
# A model or server that can't take images (see VisionUnsupported).
|
|
229
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
|
|
230
241
|
RETRYABLE_SERVER_STATUSES = [500, 502, 503, 504, 529].freeze
|
|
231
242
|
|
|
232
243
|
module_function
|
|
@@ -248,7 +259,10 @@ module Samagotchi
|
|
|
248
259
|
when 401, 403 then AuthError.new(text, **options)
|
|
249
260
|
when 429 then RateLimited.new(text, retry_after: parse_retry_after(retry_after), **options)
|
|
250
261
|
when 408 then ServerError.new(text, retryable: true, **options)
|
|
251
|
-
when 400..499
|
|
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)
|
|
252
266
|
when 500..599
|
|
253
267
|
ServerError.new(text, retryable: RETRYABLE_SERVER_STATUSES.include?(status),
|
|
254
268
|
retry_after: parse_retry_after(retry_after), **options)
|
|
@@ -269,9 +283,26 @@ module Samagotchi
|
|
|
269
283
|
return nil
|
|
270
284
|
end
|
|
271
285
|
|
|
286
|
+
from_error_json(raw, host: host)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# The error a 200's whole body carries when it is a plain JSON error
|
|
290
|
+
# object instead of a stream (OpenRouter's "503 inside a 200":
|
|
291
|
+
# {"error":{"code":503,"message":"…"}}), or nil for anything else.
|
|
292
|
+
def from_error_body(body, host:)
|
|
293
|
+
parsed = parse_json(body.to_s)
|
|
294
|
+
return nil unless parsed.is_a?(Hash) && parsed.key?("error")
|
|
295
|
+
|
|
296
|
+
from_error_json(body.to_s, host: host)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# The ProviderError for a JSON error object, by its error.code (an HTTP
|
|
300
|
+
# status); 500 when it has none.
|
|
301
|
+
def from_error_json(raw, host:)
|
|
272
302
|
parsed = parse_json(raw)
|
|
273
303
|
error = parsed.is_a?(Hash) && parsed["error"].is_a?(Hash) ? parsed["error"] : parsed
|
|
274
304
|
code = error.is_a?(Hash) ? error["code"] : nil
|
|
305
|
+
code = code.to_i if code.is_a?(String) && code.match?(/\A\d{3}\z/)
|
|
275
306
|
status = code.is_a?(Integer) && code.between?(400, 599) ? code : 500
|
|
276
307
|
from_response(status: status, body: raw, host: host)
|
|
277
308
|
end
|
data/lib/samagotchi/llm/http.rb
CHANGED
|
@@ -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
|
|
@@ -20,9 +21,6 @@ module Samagotchi
|
|
|
20
21
|
# the listener falls back to raising RequestCancelled in the requesting
|
|
21
22
|
# thread, as Client did before.
|
|
22
23
|
class HTTP
|
|
23
|
-
RETRY_MAX_ENV = "SAMAGOTCHI_RETRY_MAX"
|
|
24
|
-
RETRY_BASE_DELAY_ENV = "SAMAGOTCHI_RETRY_BASE_DELAY"
|
|
25
|
-
RETRY_MAX_DELAY_ENV = "SAMAGOTCHI_RETRY_MAX_DELAY"
|
|
26
24
|
DEFAULT_RETRY_MAX = 5
|
|
27
25
|
DEFAULT_RETRY_BASE_DELAY = 0.5
|
|
28
26
|
DEFAULT_RETRY_MAX_DELAY = 8.0
|
|
@@ -45,15 +43,14 @@ module Samagotchi
|
|
|
45
43
|
# Exponential backoff: base_delay * 2^(attempt-1), capped at max_delay,
|
|
46
44
|
# for up to +max+ retries (max + 1 attempts).
|
|
47
45
|
RetryPolicy = Data.define(:max, :base_delay, :max_delay) do
|
|
48
|
-
# retry.*
|
|
46
|
+
# retry.* (Config); a value out of range is the default.
|
|
49
47
|
def self.from_config
|
|
50
48
|
new(
|
|
51
|
-
max: config_value("retry.max") { |v| v.is_a?(Integer) && v >= 0 } ||
|
|
52
|
-
env_integer(RETRY_MAX_ENV, DEFAULT_RETRY_MAX),
|
|
49
|
+
max: config_value("retry.max") { |v| v.is_a?(Integer) && v >= 0 } || DEFAULT_RETRY_MAX,
|
|
53
50
|
base_delay: config_value("retry.base_delay") { |v| v.is_a?(Numeric) && v.positive? }&.to_f ||
|
|
54
|
-
|
|
51
|
+
DEFAULT_RETRY_BASE_DELAY,
|
|
55
52
|
max_delay: config_value("retry.max_delay") { |v| v.is_a?(Numeric) && v.positive? }&.to_f ||
|
|
56
|
-
|
|
53
|
+
DEFAULT_RETRY_MAX_DELAY
|
|
57
54
|
)
|
|
58
55
|
end
|
|
59
56
|
|
|
@@ -66,16 +63,6 @@ module Samagotchi
|
|
|
66
63
|
nil
|
|
67
64
|
end
|
|
68
65
|
|
|
69
|
-
def self.env_integer(name, default)
|
|
70
|
-
value = ENV.fetch(name, default.to_s).to_i
|
|
71
|
-
value.negative? ? default : value
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def self.env_float(name, default)
|
|
75
|
-
value = ENV.fetch(name, default.to_s).to_f
|
|
76
|
-
value.positive? ? value : default
|
|
77
|
-
end
|
|
78
|
-
|
|
79
66
|
# Seconds to wait before retrying after failed attempt +attempt+
|
|
80
67
|
# (1-based), or nil when the budget is spent.
|
|
81
68
|
def delay_for(attempt)
|
|
@@ -101,8 +88,12 @@ module Samagotchi
|
|
|
101
88
|
# @param sleeper [#call] waits the given seconds (specs pass a no-op)
|
|
102
89
|
# @param first_token_timeout [Numeric, nil] seconds a stream may take to
|
|
103
90
|
# show something (see #stream_lines); nil: no limit
|
|
104
|
-
|
|
91
|
+
# @param api_key [ApiKey, nil] sent on every request; nil: no
|
|
92
|
+
# Authorization header (a local server)
|
|
93
|
+
def initialize(label:, open_timeout:, read_timeout:, retry_policy: nil, sleeper: nil, first_token_timeout: nil,
|
|
94
|
+
api_key: nil)
|
|
105
95
|
@label = label
|
|
96
|
+
@api_key = api_key
|
|
106
97
|
@open_timeout = open_timeout
|
|
107
98
|
@read_timeout = read_timeout
|
|
108
99
|
@first_token_timeout = first_token_timeout&.positive? ? first_token_timeout : nil
|
|
@@ -210,8 +201,11 @@ module Samagotchi
|
|
|
210
201
|
|
|
211
202
|
private
|
|
212
203
|
|
|
204
|
+
# User-Agent, and the host's key when it has one (AuthError, before
|
|
205
|
+
# any request, when its variable is not set).
|
|
213
206
|
def identify(request)
|
|
214
207
|
request["User-Agent"] = Samagotchi::USER_AGENT
|
|
208
|
+
@api_key&.authorize(request)
|
|
215
209
|
end
|
|
216
210
|
|
|
217
211
|
def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &block)
|
|
@@ -388,7 +382,8 @@ module Samagotchi
|
|
|
388
382
|
status: error.is_a?(ProviderError) ? error.status : nil, error: error.class.name,
|
|
389
383
|
msg: error.message.to_s[0, 300])
|
|
390
384
|
on_retry&.call(attempt: attempts, max_retries: @retry_policy.max, next_delay: delay,
|
|
391
|
-
error_class: error.class.name, error_message: error.message
|
|
385
|
+
error_class: error.class.name, error_message: error.message,
|
|
386
|
+
status: error.is_a?(ProviderError) ? error.status : nil)
|
|
392
387
|
wait(delay, cancel_controller)
|
|
393
388
|
end
|
|
394
389
|
|
|
@@ -396,8 +391,10 @@ module Samagotchi
|
|
|
396
391
|
status = response.code.to_i
|
|
397
392
|
return if status.between?(200, 299)
|
|
398
393
|
|
|
399
|
-
|
|
400
|
-
|
|
394
|
+
error = ProviderErrors.from_response(status: status, body: response.body.to_s, host: @label,
|
|
395
|
+
retry_after: response["Retry-After"])
|
|
396
|
+
error.hint ||= @api_key ? @api_key.hint : ApiKey.missing_hint(@label) if error.is_a?(AuthError)
|
|
397
|
+
raise error
|
|
401
398
|
end
|
|
402
399
|
|
|
403
400
|
def abort_request(current, requesting_thread, reason)
|
|
@@ -44,6 +44,8 @@ module Samagotchi
|
|
|
44
44
|
# only; the chat loop owns the conversation and the tools.
|
|
45
45
|
class OpenAIChat
|
|
46
46
|
MAX_MODEL_PAGES = 20
|
|
47
|
+
# How much of a 200's non-stream body is read as a possible JSON error.
|
|
48
|
+
PLAIN_ERROR_LIMIT = 64 * 1024
|
|
47
49
|
DEFAULT_MODELS_TTL = 60
|
|
48
50
|
|
|
49
51
|
attr_reader :base_url, :host_name, :api_key_env, :models_ttl, :first_token_timeout
|
|
@@ -90,13 +92,13 @@ module Samagotchi
|
|
|
90
92
|
@host_name = host_name.to_s
|
|
91
93
|
@api_key_env = api_key_env
|
|
92
94
|
@stream = stream
|
|
93
|
-
@env = env
|
|
94
95
|
@models_ttl = models_ttl
|
|
95
96
|
@models_mutex = Mutex.new
|
|
96
97
|
open_timeout, read_timeout = timeouts(timeout)
|
|
97
98
|
policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
|
|
98
99
|
@http = HTTP.new(label: @host_name, open_timeout: open_timeout, read_timeout: read_timeout,
|
|
99
|
-
retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout
|
|
100
|
+
retry_policy: policy, sleeper: sleeper, first_token_timeout: first_token_timeout,
|
|
101
|
+
api_key: ApiKey.for(api_key_env, host: @host_name, env: env))
|
|
100
102
|
end
|
|
101
103
|
|
|
102
104
|
# @param messages [Array<Hash>] wire messages (role, content, tool_calls,
|
|
@@ -120,15 +122,18 @@ module Samagotchi
|
|
|
120
122
|
assembly = Assembly.new
|
|
121
123
|
events = 0
|
|
122
124
|
other = +""
|
|
125
|
+
plain = +""
|
|
123
126
|
# A retry streams the answer from the start again.
|
|
124
127
|
restart = lambda do |**event|
|
|
125
128
|
assembly = Assembly.new
|
|
129
|
+
plain = +""
|
|
126
130
|
on_retry&.call(**event)
|
|
127
131
|
end
|
|
128
132
|
@http.stream_lines(URI(request.uri.to_s), request, cancel_controller: cancel_controller, on_retry: restart,
|
|
129
133
|
log_fields: log_fields) do |line, shown|
|
|
130
134
|
events += 1 if line.start_with?("data:")
|
|
131
135
|
other << line[0, 200] if !line.start_with?("data:") && other.length < 200
|
|
136
|
+
raise_plain_error(plain, line) if events.zero?
|
|
132
137
|
payload = parse_line(line)
|
|
133
138
|
next unless payload
|
|
134
139
|
|
|
@@ -184,20 +189,13 @@ module Samagotchi
|
|
|
184
189
|
private
|
|
185
190
|
|
|
186
191
|
def timeouts(timeout)
|
|
187
|
-
open_timeout =
|
|
188
|
-
read_timeout =
|
|
192
|
+
open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout")
|
|
193
|
+
read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout")
|
|
189
194
|
return [open_timeout, read_timeout] unless timeout
|
|
190
195
|
|
|
191
196
|
[[open_timeout, timeout].min, timeout]
|
|
192
197
|
end
|
|
193
198
|
|
|
194
|
-
def config_integer(key, default)
|
|
195
|
-
value = Samagotchi::Config.get(key).to_i
|
|
196
|
-
value.positive? ? value : default
|
|
197
|
-
rescue StandardError
|
|
198
|
-
default
|
|
199
|
-
end
|
|
200
|
-
|
|
201
199
|
def request_body(messages, tools, model, options)
|
|
202
200
|
body = {
|
|
203
201
|
model: model,
|
|
@@ -263,6 +261,18 @@ module Samagotchi
|
|
|
263
261
|
|
|
264
262
|
# The parsed payload of a `data:` line; nil for blank lines, comments
|
|
265
263
|
# and [DONE]. Error events raise their ProviderError.
|
|
264
|
+
# A 200 whose body is a plain JSON error instead of a stream (OpenRouter
|
|
265
|
+
# sends an upstream 503 so): raised, from inside the stream so it is
|
|
266
|
+
# retried by its kind, once the lines so far parse as an error object.
|
|
267
|
+
def raise_plain_error(plain, line)
|
|
268
|
+
return if line.empty? || line.start_with?(":") || (plain.empty? && !line.start_with?("{"))
|
|
269
|
+
return if plain.bytesize > PLAIN_ERROR_LIMIT
|
|
270
|
+
|
|
271
|
+
plain << line << "\n"
|
|
272
|
+
error = ProviderErrors.from_error_body(plain, host: @host_name)
|
|
273
|
+
raise error if error
|
|
274
|
+
end
|
|
275
|
+
|
|
266
276
|
def parse_line(line)
|
|
267
277
|
error = HTTP.sse_error(line, host: @host_name)
|
|
268
278
|
raise error if error
|
|
@@ -286,25 +296,11 @@ module Samagotchi
|
|
|
286
296
|
Net::HTTP::Post.new(uri).tap do |request|
|
|
287
297
|
request["Content-Type"] = "application/json"
|
|
288
298
|
request["Session-Id"] = session_id.to_s unless session_id.to_s.empty?
|
|
289
|
-
authorize(request)
|
|
290
299
|
request.body = JSON.generate(body)
|
|
291
300
|
end
|
|
292
301
|
end
|
|
293
302
|
|
|
294
|
-
def get_request(uri)
|
|
295
|
-
Net::HTTP::Get.new(uri).tap { |request| authorize(request) }
|
|
296
|
-
end
|
|
297
|
-
|
|
298
|
-
def authorize(request)
|
|
299
|
-
return unless @api_key_env
|
|
300
|
-
|
|
301
|
-
key = @env[@api_key_env].to_s
|
|
302
|
-
if key.strip.empty?
|
|
303
|
-
raise AuthError.new("#{@host_name}: set #{@api_key_env} (the API key for host #{@host_name})",
|
|
304
|
-
host: @host_name)
|
|
305
|
-
end
|
|
306
|
-
request["Authorization"] = "Bearer #{key}"
|
|
307
|
-
end
|
|
303
|
+
def get_request(uri) = Net::HTTP::Get.new(uri)
|
|
308
304
|
|
|
309
305
|
def model_entries(body)
|
|
310
306
|
return [] unless body.is_a?(Hash)
|
|
@@ -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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
if
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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.
|
|
@@ -94,14 +94,23 @@ module Samagotchi
|
|
|
94
94
|
end
|
|
95
95
|
end
|
|
96
96
|
|
|
97
|
+
# Yields [name, data] for each installed bundle with hooks, by name.
|
|
98
|
+
# A manifest that doesn't parse is yielded as {error:} when its
|
|
99
|
+
# bundle has a hooks/ dir, and skipped otherwise; the rest still load.
|
|
97
100
|
def self.each_installed_holding_hooks
|
|
98
101
|
return enum_for(:each_installed_holding_hooks) unless block_given?
|
|
99
102
|
dir = self.bundles_dir
|
|
100
103
|
return unless Dir.exist?(dir)
|
|
101
104
|
Dir[File.join(dir, "*", "manifest.json")].sort.each do |mjson|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
name = File.basename(File.dirname(mjson))
|
|
106
|
+
begin
|
|
107
|
+
data = JSON.parse(File.read(mjson), symbolize_names: true)
|
|
108
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
109
|
+
yield name, { error: "manifest.json is unreadable: #{e.message}" } if Dir.exist?(File.join(File.dirname(mjson), "hooks"))
|
|
110
|
+
next
|
|
111
|
+
end
|
|
112
|
+
next unless data.is_a?(Hash) && data[:hooks].is_a?(Hash) && !data[:hooks].empty?
|
|
113
|
+
yield name, data
|
|
105
114
|
end
|
|
106
115
|
end
|
|
107
116
|
|
|
@@ -116,8 +125,10 @@ module Samagotchi
|
|
|
116
125
|
# snapshot
|
|
117
126
|
# @param requires_chi [String, nil] the manifest's requirement
|
|
118
127
|
# @param needs [Array<Hash>] the manifest's needs ({command:, why:, hint:})
|
|
128
|
+
# @param conflicts [Array<String>] files an upgrade kept with local
|
|
129
|
+
# edits that conflict with it; their entries get conflict: true
|
|
119
130
|
def write(files:, scope:, version:, source_path:, hooks: {}, trust_level: nil, source_commit: nil, hooks_files: {},
|
|
120
|
-
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil)
|
|
131
|
+
guardrails_files: {}, plugin_file: nil, requires_chi: nil, needs: nil, conflicts: [])
|
|
121
132
|
FileUtils.mkdir_p(@bundle_dir)
|
|
122
133
|
bases_dir = File.join(@bundle_dir, "bases")
|
|
123
134
|
FileUtils.mkdir_p(bases_dir)
|
|
@@ -134,6 +145,7 @@ module Samagotchi
|
|
|
134
145
|
# Save base snapshot — use file_key as-is (it already includes .md).
|
|
135
146
|
File.write(File.join(bases_dir, file_key), content)
|
|
136
147
|
merged_entries[file_key] = { checksum: checksum }
|
|
148
|
+
merged_entries[file_key][:conflict] = true if conflicts.include?(file_key.to_s)
|
|
137
149
|
end
|
|
138
150
|
|
|
139
151
|
# Prune stale base snapshots for files no longer in the bundle.
|
|
@@ -226,14 +238,29 @@ module Samagotchi
|
|
|
226
238
|
manifest_data["requires_chi"] = requires_chi.to_s if requires_chi && !requires_chi.to_s.empty?
|
|
227
239
|
manifest_data["needs"] = needs.map { |n| n.transform_keys(&:to_s).compact } if needs.is_a?(Array) && !needs.empty?
|
|
228
240
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
241
|
+
write_manifest(manifest_data)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# After a conflict was resolved by hand: each file's base becomes the
|
|
245
|
+
# bundle's version (the incoming file, else what's on disk now) and
|
|
246
|
+
# its conflict mark goes. The rest of the manifest stays as is.
|
|
247
|
+
# @param conflicts [Hash{String => Hash}] Installer#conflicts
|
|
248
|
+
def resolve_conflicts(conflicts)
|
|
249
|
+
return unless installed?
|
|
250
|
+
|
|
251
|
+
raw = JSON.parse(File.read(File.join(@bundle_dir, "manifest.json")))
|
|
252
|
+
raw["files"] ||= {}
|
|
253
|
+
conflicts.each do |file_key, info|
|
|
254
|
+
key = file_key.to_s
|
|
255
|
+
next unless raw["files"].key?(key)
|
|
256
|
+
src = [info[:incoming], info[:current]].find { |p| p && File.exist?(p) }
|
|
257
|
+
next unless src
|
|
258
|
+
|
|
259
|
+
content = File.read(src)
|
|
260
|
+
File.write(base_path(key), content)
|
|
261
|
+
raw["files"][key] = { "checksum" => Digest::SHA256.hexdigest(content) }
|
|
262
|
+
end
|
|
263
|
+
write_manifest(raw)
|
|
237
264
|
end
|
|
238
265
|
|
|
239
266
|
# Reads provenance data (returns nil if not installed).
|
|
@@ -243,6 +270,18 @@ module Samagotchi
|
|
|
243
270
|
JSON.parse(File.read(manifest_path), symbolize_names: true)
|
|
244
271
|
end
|
|
245
272
|
|
|
273
|
+
# Write aside and rename, so a reader in another process (a parallel
|
|
274
|
+
# chi start) never parses a truncated manifest.json.
|
|
275
|
+
def write_manifest(manifest_data)
|
|
276
|
+
manifest_path = File.join(@bundle_dir, "manifest.json")
|
|
277
|
+
tmp_path = "#{manifest_path}.#{Process.pid}.tmp"
|
|
278
|
+
File.write(tmp_path, JSON.pretty_generate(manifest_data))
|
|
279
|
+
File.rename(tmp_path, manifest_path)
|
|
280
|
+
ensure
|
|
281
|
+
FileUtils.rm_f(tmp_path) if tmp_path
|
|
282
|
+
end
|
|
283
|
+
private :write_manifest
|
|
284
|
+
|
|
246
285
|
# Returns the path to a base snapshot for a given file key.
|
|
247
286
|
def base_path(file_key)
|
|
248
287
|
File.join(@bundle_dir, "bases", file_key)
|