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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -1
  3. data/README.md +16 -0
  4. data/bin/chi +32 -31
  5. data/docs/cli.md +77 -5
  6. data/docs/configuration.md +104 -2
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +88 -6
  10. data/docs/releasing.md +6 -6
  11. data/docs/sessions.md +19 -17
  12. data/lib/samagotchi/bridge/turn_accumulator.rb +1 -0
  13. data/lib/samagotchi/bridge.rb +4 -1
  14. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  15. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  16. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  17. data/lib/samagotchi/bundles/system/config_modification_protocol.md +2 -2
  18. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  19. data/lib/samagotchi/bundles/system/manifest.yml +3 -3
  20. data/lib/samagotchi/client.rb +9 -6
  21. data/lib/samagotchi/commands/registry.rb +8 -0
  22. data/lib/samagotchi/config.rb +64 -20
  23. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  24. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  25. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  26. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  27. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  28. data/lib/samagotchi/desktop/macos.rb +59 -8
  29. data/lib/samagotchi/desktop_command.rb +6 -3
  30. data/lib/samagotchi/edit_preview.rb +82 -0
  31. data/lib/samagotchi/engine.rb +201 -104
  32. data/lib/samagotchi/gem_update.rb +89 -0
  33. data/lib/samagotchi/guardrails/approval.rb +26 -4
  34. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  35. data/lib/samagotchi/host_registry.rb +8 -12
  36. data/lib/samagotchi/idle_client.rb +24 -15
  37. data/lib/samagotchi/idle_reminders.rb +2 -2
  38. data/lib/samagotchi/image_store.rb +10 -6
  39. data/lib/samagotchi/kernel_loop.rb +26 -79
  40. data/lib/samagotchi/live_versions.rb +59 -0
  41. data/lib/samagotchi/llm/api_key.rb +41 -0
  42. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  43. data/lib/samagotchi/llm/errors.rb +21 -7
  44. data/lib/samagotchi/llm/http.rb +15 -4
  45. data/lib/samagotchi/llm/openai_chat.rb +5 -26
  46. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  47. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  48. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  49. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  50. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  51. data/lib/samagotchi/model_profile.rb +23 -0
  52. data/lib/samagotchi/prompt.rb +4 -2
  53. data/lib/samagotchi/reminder_store.rb +1 -9
  54. data/lib/samagotchi/self_report.rb +17 -3
  55. data/lib/samagotchi/send_command.rb +107 -12
  56. data/lib/samagotchi/session_commands.rb +38 -8
  57. data/lib/samagotchi/session_manager.rb +1 -16
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +21 -25
  59. data/lib/samagotchi/terminal_ui/event_renderer.rb +8 -3
  60. data/lib/samagotchi/terminal_ui/formatting.rb +9 -0
  61. data/lib/samagotchi/terminal_ui/input_support.rb +4 -19
  62. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  63. data/lib/samagotchi/terminal_ui.rb +62 -248
  64. data/lib/samagotchi/text_diff.rb +181 -0
  65. data/lib/samagotchi/thinking.rb +115 -0
  66. data/lib/samagotchi/tool_runner.rb +34 -1
  67. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  68. data/lib/samagotchi/tools/edit.rb +23 -9
  69. data/lib/samagotchi/tools/write.rb +4 -0
  70. data/lib/samagotchi/turn_flow.rb +12 -2
  71. data/lib/samagotchi/update_command.rb +308 -0
  72. data/lib/samagotchi/update_hint.rb +59 -0
  73. data/lib/samagotchi/version.rb +1 -1
  74. data/lib/samagotchi/vision_support.rb +6 -4
  75. data/lib/samagotchi/web/app.rb +3 -3
  76. data/lib/samagotchi/web/message_parts.rb +8 -3
  77. data/lib/samagotchi/web/public/activity.js +3 -0
  78. data/lib/samagotchi/web/public/app.js +36 -24
  79. data/lib/samagotchi/web/public/chat_view.js +3 -0
  80. data/lib/samagotchi/web/public/data.js +2 -0
  81. data/lib/samagotchi/web/public/diff_view.js +58 -0
  82. data/lib/samagotchi/web/public/index.html +22 -1
  83. data/lib/samagotchi/web/public/question_card.js +3 -1
  84. data/lib/samagotchi/web/public/turn_events.js +29 -5
  85. data/lib/samagotchi/web/public/turn_view.js +2 -1
  86. data/lib/samagotchi/worker.rb +5 -4
  87. 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
- options = @loop.sampling
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 = @loop.adapter.chat(
375
- messages: @loop.wire_messages(@conversation), tools: @loop.tool_definitions, model: @model_name,
376
- cancel_controller: @cancel_controller, session_id: @loop.session_id, options: options,
377
- on_delta: lambda { |content:, reasoning:, payload:|
378
- streamed << content
379
- emit(type: :generation_chunk, iteration: iteration, content: reasoning + content, text: content,
380
- thinking: reasoning, payload: payload)
381
- },
382
- on_retry: ->(**retry_event) { emit({ type: :generation_retrying, iteration: iteration }.merge(retry_event)) }
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, **options)
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 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)
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)
@@ -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
@@ -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
- raise ProviderErrors.from_response(status: status, body: response.body.to_s, host: @label,
400
- 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
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 = config_integer("server.open_timeout", 10)
188
- 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")
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
- 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.
@@ -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
- data = JSON.parse(File.read(mjson), symbolize_names: true)
103
- next unless data && data[:hooks].is_a?(Hash) && !data[:hooks].empty?
104
- yield File.basename(File.dirname(mjson)), data
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
- # Write aside and rename, so a reader in another process (a parallel
230
- # chi start) never parses a truncated manifest.json.
231
- manifest_path = File.join(@bundle_dir, "manifest.json")
232
- tmp_path = "#{manifest_path}.#{Process.pid}.tmp"
233
- File.write(tmp_path, JSON.pretty_generate(manifest_data))
234
- File.rename(tmp_path, manifest_path)
235
- ensure
236
- FileUtils.rm_f(tmp_path) if tmp_path
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)