samagotchi 0.3.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +83 -1
- data/README.md +16 -0
- data/bin/chi +32 -31
- data/docs/cli.md +77 -5
- data/docs/configuration.md +104 -2
- data/docs/desktop.md +39 -4
- data/docs/guardrails.md +11 -0
- data/docs/hooks.md +88 -6
- data/docs/releasing.md +6 -6
- data/docs/sessions.md +19 -17
- data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
- data/lib/samagotchi/bridge.rb +4 -1
- 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 +2 -2
- data/lib/samagotchi/bundles/system/delegated.md +6 -7
- data/lib/samagotchi/bundles/system/manifest.yml +3 -3
- data/lib/samagotchi/client.rb +9 -6
- data/lib/samagotchi/commands/registry.rb +8 -0
- data/lib/samagotchi/config.rb +64 -20
- 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 +201 -104
- 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 +26 -79
- data/lib/samagotchi/live_versions.rb +59 -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 +21 -7
- data/lib/samagotchi/llm/http.rb +15 -4
- data/lib/samagotchi/llm/openai_chat.rb +5 -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 +23 -0
- data/lib/samagotchi/prompt.rb +4 -2
- data/lib/samagotchi/reminder_store.rb +1 -9
- data/lib/samagotchi/self_report.rb +17 -3
- data/lib/samagotchi/send_command.rb +107 -12
- data/lib/samagotchi/session_commands.rb +38 -8
- data/lib/samagotchi/session_manager.rb +1 -16
- data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
- data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
- data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
- data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
- data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
- data/lib/samagotchi/terminal_ui.rb +62 -248
- data/lib/samagotchi/text_diff.rb +181 -0
- data/lib/samagotchi/thinking.rb +115 -0
- data/lib/samagotchi/tool_runner.rb +34 -1
- data/lib/samagotchi/tools/ask_user_question.rb +41 -33
- data/lib/samagotchi/tools/edit.rb +23 -9
- data/lib/samagotchi/tools/write.rb +4 -0
- data/lib/samagotchi/turn_flow.rb +12 -2
- data/lib/samagotchi/update_command.rb +308 -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 +3 -3
- data/lib/samagotchi/web/message_parts.rb +8 -3
- data/lib/samagotchi/web/public/activity.js +3 -0
- data/lib/samagotchi/web/public/app.js +36 -24
- data/lib/samagotchi/web/public/chat_view.js +3 -0
- 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 +22 -1
- data/lib/samagotchi/web/public/question_card.js +3 -1
- data/lib/samagotchi/web/public/turn_events.js +29 -5
- data/lib/samagotchi/web/public/turn_view.js +2 -1
- data/lib/samagotchi/worker.rb +5 -4
- metadata +12 -1
|
@@ -17,6 +17,7 @@ require_relative "../vision_context"
|
|
|
17
17
|
require_relative "../log"
|
|
18
18
|
require_relative "../empty_answer_retry"
|
|
19
19
|
require_relative "../turn_note"
|
|
20
|
+
require_relative "../thinking"
|
|
20
21
|
|
|
21
22
|
module Samagotchi
|
|
22
23
|
module LLM
|
|
@@ -139,6 +140,34 @@ module Samagotchi
|
|
|
139
140
|
@kernel.respond_to?(:sampling) ? @kernel.sampling || {} : {}
|
|
140
141
|
end
|
|
141
142
|
|
|
143
|
+
# The turn's thinking level (the Engine sets it on the kernel).
|
|
144
|
+
def thinking
|
|
145
|
+
(@kernel.respond_to?(:thinking) && @kernel.thinking) || Thinking::DEFAULT
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# The request fields the thinking level adds (Thinking.chat_fields);
|
|
149
|
+
# none for a model whose host refused them (#thinking_refused!).
|
|
150
|
+
def thinking_fields(model = nil)
|
|
151
|
+
return {} if model && (@thinking_refused ||= Set.new).include?(model)
|
|
152
|
+
|
|
153
|
+
Thinking.chat_fields(thinking)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The host refused +model+'s thinking fields: leave them out from now on.
|
|
157
|
+
def thinking_refused!(model)
|
|
158
|
+
(@thinking_refused ||= Set.new) << model
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# One generation's options: the thinking fields under the sampling
|
|
162
|
+
# (a sampling key wins, chat_template_kwargs merges per sub-key), the
|
|
163
|
+
# empty-answer retry's temperature on top, then every null dropped at
|
|
164
|
+
# any depth (a sampling null means "don't send it").
|
|
165
|
+
def request_options(retry_generation: false, model: nil)
|
|
166
|
+
options = deep_merge(thinking_fields(model), sampling)
|
|
167
|
+
options = EmptyAnswerRetry.sampling(options) if retry_generation
|
|
168
|
+
deep_compact(options)
|
|
169
|
+
end
|
|
170
|
+
|
|
142
171
|
def strip_model_thought(text)
|
|
143
172
|
@kernel.respond_to?(:strip_model_thought) ? @kernel.strip_model_thought(text) : text
|
|
144
173
|
end
|
|
@@ -180,7 +209,7 @@ module Samagotchi
|
|
|
180
209
|
# reasoning, never sent back), a result's tool_call_id, the image
|
|
181
210
|
# refs of a user message or a tool result, and a plugin tool result's
|
|
182
211
|
# tool_params and tool_labels (the live row's params line and label,
|
|
183
|
-
# never sent back).
|
|
212
|
+
# never sent back), and an edit/write result's tool_diffs (never sent).
|
|
184
213
|
def plain(conversation)
|
|
185
214
|
conversation.map do |entry|
|
|
186
215
|
content = entry[:content].is_a?(Array) ? entry[:content] : entry[:content].to_s
|
|
@@ -191,6 +220,7 @@ module Samagotchi
|
|
|
191
220
|
message[:thinking] = entry[:thinking] if entry[:thinking].is_a?(String) && !entry[:thinking].empty?
|
|
192
221
|
message[:tool_params] = entry[:tool_params] if entry[:tool_params]
|
|
193
222
|
message[:tool_labels] = entry[:tool_labels] if entry[:tool_labels]
|
|
223
|
+
message[:tool_diffs] = entry[:tool_diffs] if entry[:tool_diffs]
|
|
194
224
|
message[AnswerDisplay::KEY] = entry[AnswerDisplay::KEY] if entry[AnswerDisplay::KEY]
|
|
195
225
|
ContextNote::KEYS.each { |key| message[key] = entry[key] if entry.key?(key) }
|
|
196
226
|
message
|
|
@@ -199,6 +229,18 @@ module Samagotchi
|
|
|
199
229
|
|
|
200
230
|
private
|
|
201
231
|
|
|
232
|
+
def deep_merge(base, over)
|
|
233
|
+
base.merge(over) { |_key, a, b| a.is_a?(Hash) && b.is_a?(Hash) ? deep_merge(a, b) : b }
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def deep_compact(hash)
|
|
237
|
+
hash.each_with_object({}) do |(key, value), out|
|
|
238
|
+
next if value.nil?
|
|
239
|
+
|
|
240
|
+
out[key] = value.is_a?(Hash) ? deep_compact(value) : value
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
202
244
|
# Ids of the calls whose assistant turn is followed by a tool message
|
|
203
245
|
# for every one of them (before the next non-tool message).
|
|
204
246
|
def paired_call_ids(conversation)
|
|
@@ -364,23 +406,22 @@ module Samagotchi
|
|
|
364
406
|
# text] when it was cancelled.
|
|
365
407
|
def generate(iteration)
|
|
366
408
|
window = @window = @loop.context_window(@model_name)
|
|
367
|
-
|
|
368
|
-
options = EmptyAnswerRetry.sampling(options) if @retry_generation
|
|
409
|
+
retry_generation = @retry_generation
|
|
369
410
|
@retry_generation = false
|
|
370
411
|
emit(type: :generation_started, iteration: iteration, context_window_tokens: window&.tokens,
|
|
371
412
|
context_window_source: window&.source)
|
|
372
413
|
@loop.fire_hook(:before_generation, { type: :before_generation, iteration: iteration })
|
|
373
414
|
streamed = +""
|
|
374
|
-
response =
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
415
|
+
response = begin
|
|
416
|
+
request(iteration, retry_generation, streamed)
|
|
417
|
+
rescue BadRequest => e
|
|
418
|
+
raise unless thinking_refused?(e)
|
|
419
|
+
|
|
420
|
+
# Once per model: asked again without the thinking fields.
|
|
421
|
+
@loop.thinking_refused!(@model_name)
|
|
422
|
+
emit(type: :thinking_refused, iteration: iteration, model: @model_name, level: @loop.thinking, detail: e.detail)
|
|
423
|
+
request(iteration, retry_generation, streamed)
|
|
424
|
+
end
|
|
384
425
|
record_context_status(response.usage, window)
|
|
385
426
|
emit(type: :generation_completed, iteration: iteration, content_length: response.text.length,
|
|
386
427
|
thinking_chars: response.reasoning.to_s.length, served_model: response.model,
|
|
@@ -393,6 +434,27 @@ module Samagotchi
|
|
|
393
434
|
[e.reason, streamed]
|
|
394
435
|
end
|
|
395
436
|
|
|
437
|
+
def request(iteration, retry_generation, streamed)
|
|
438
|
+
@loop.adapter.chat(
|
|
439
|
+
messages: @loop.wire_messages(@conversation), tools: @loop.tool_definitions, model: @model_name,
|
|
440
|
+
cancel_controller: @cancel_controller, session_id: @loop.session_id,
|
|
441
|
+
options: @loop.request_options(retry_generation: retry_generation, model: @model_name),
|
|
442
|
+
on_delta: lambda { |content:, reasoning:, payload:|
|
|
443
|
+
streamed << content
|
|
444
|
+
emit(type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
|
|
445
|
+
thinking: reasoning, payload: payload)
|
|
446
|
+
},
|
|
447
|
+
on_retry: ->(**retry_event) { emit({ type: :generation_retrying, iteration: iteration }.merge(retry_event)) }
|
|
448
|
+
)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# A 400 about reasoning, for a request that carried thinking fields
|
|
452
|
+
# (not a missing-tools, image or context error).
|
|
453
|
+
def thinking_refused?(error)
|
|
454
|
+
error.reasoning_refused? && !error.is_a?(VisionUnsupported) && !error.tools_unsupported? &&
|
|
455
|
+
!error.context_overflow? && !@loop.thinking_fields(@model_name).empty?
|
|
456
|
+
end
|
|
457
|
+
|
|
396
458
|
# The status line's value from the server's counts for this request
|
|
397
459
|
# (prompt + answer); without them the last value stays.
|
|
398
460
|
def record_context_status(usage, window)
|
|
@@ -429,6 +491,8 @@ module Samagotchi
|
|
|
429
491
|
# A plugin tool's params line, for the web's reload; never sent.
|
|
430
492
|
entry[:tool_params] = run[:shown_params] if run[:shown_params]
|
|
431
493
|
entry[:tool_labels] = run[:shown_label] if run[:shown_label]
|
|
494
|
+
# What an edit/write changed, for the web's reload; never sent.
|
|
495
|
+
entry[:tool_diffs] = run[:diff] if run[:diff]
|
|
432
496
|
@conversation << entry
|
|
433
497
|
end
|
|
434
498
|
emit(type: :tool_dispatch_completed, iteration: iteration, call_count: tool_calls.length)
|
|
@@ -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)
|
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
|
|
@@ -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
|
-
|
|
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
|
|
@@ -210,8 +215,11 @@ module Samagotchi
|
|
|
210
215
|
|
|
211
216
|
private
|
|
212
217
|
|
|
218
|
+
# User-Agent, and the host's key when it has one (AuthError, before
|
|
219
|
+
# any request, when its variable is not set).
|
|
213
220
|
def identify(request)
|
|
214
221
|
request["User-Agent"] = Samagotchi::USER_AGENT
|
|
222
|
+
@api_key&.authorize(request)
|
|
215
223
|
end
|
|
216
224
|
|
|
217
225
|
def start(uri, open_timeout: nil, read_timeout: nil, max_retries: nil, &block)
|
|
@@ -388,7 +396,8 @@ module Samagotchi
|
|
|
388
396
|
status: error.is_a?(ProviderError) ? error.status : nil, error: error.class.name,
|
|
389
397
|
msg: error.message.to_s[0, 300])
|
|
390
398
|
on_retry&.call(attempt: attempts, max_retries: @retry_policy.max, next_delay: delay,
|
|
391
|
-
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)
|
|
392
401
|
wait(delay, cancel_controller)
|
|
393
402
|
end
|
|
394
403
|
|
|
@@ -396,8 +405,10 @@ module Samagotchi
|
|
|
396
405
|
status = response.code.to_i
|
|
397
406
|
return if status.between?(200, 299)
|
|
398
407
|
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|
401
412
|
end
|
|
402
413
|
|
|
403
414
|
def abort_request(current, requesting_thread, reason)
|
|
@@ -90,13 +90,13 @@ module Samagotchi
|
|
|
90
90
|
@host_name = host_name.to_s
|
|
91
91
|
@api_key_env = api_key_env
|
|
92
92
|
@stream = stream
|
|
93
|
-
@env = env
|
|
94
93
|
@models_ttl = models_ttl
|
|
95
94
|
@models_mutex = Mutex.new
|
|
96
95
|
open_timeout, read_timeout = timeouts(timeout)
|
|
97
96
|
policy = retry_policy || (retries ? nil : HTTP::RetryPolicy.none)
|
|
98
97
|
@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
|
|
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))
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
# @param messages [Array<Hash>] wire messages (role, content, tool_calls,
|
|
@@ -184,20 +184,13 @@ module Samagotchi
|
|
|
184
184
|
private
|
|
185
185
|
|
|
186
186
|
def timeouts(timeout)
|
|
187
|
-
open_timeout =
|
|
188
|
-
read_timeout =
|
|
187
|
+
open_timeout = Samagotchi::Config.positive_seconds("server.open_timeout")
|
|
188
|
+
read_timeout = Samagotchi::Config.positive_seconds("server.read_timeout")
|
|
189
189
|
return [open_timeout, read_timeout] unless timeout
|
|
190
190
|
|
|
191
191
|
[[open_timeout, timeout].min, timeout]
|
|
192
192
|
end
|
|
193
193
|
|
|
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
194
|
def request_body(messages, tools, model, options)
|
|
202
195
|
body = {
|
|
203
196
|
model: model,
|
|
@@ -286,25 +279,11 @@ module Samagotchi
|
|
|
286
279
|
Net::HTTP::Post.new(uri).tap do |request|
|
|
287
280
|
request["Content-Type"] = "application/json"
|
|
288
281
|
request["Session-Id"] = session_id.to_s unless session_id.to_s.empty?
|
|
289
|
-
authorize(request)
|
|
290
282
|
request.body = JSON.generate(body)
|
|
291
283
|
end
|
|
292
284
|
end
|
|
293
285
|
|
|
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
|
|
286
|
+
def get_request(uri) = Net::HTTP::Get.new(uri)
|
|
308
287
|
|
|
309
288
|
def model_entries(body)
|
|
310
289
|
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)
|