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.
Files changed (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +162 -1
  3. data/README.md +29 -2
  4. data/bin/chi +60 -69
  5. data/docs/cli.md +211 -77
  6. data/docs/configuration.md +118 -21
  7. data/docs/desktop.md +39 -4
  8. data/docs/guardrails.md +11 -0
  9. data/docs/hooks.md +89 -7
  10. data/docs/memory.md +40 -0
  11. data/docs/plugins.md +50 -0
  12. data/docs/releasing.md +15 -12
  13. data/docs/sessions.md +20 -18
  14. data/lib/samagotchi/bootstrap/config_writer.rb +1 -2
  15. data/lib/samagotchi/bridge/sse_writer.rb +0 -3
  16. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  17. data/lib/samagotchi/bridge.rb +20 -12
  18. data/lib/samagotchi/bundles/skills/manifest.yml +10 -0
  19. data/lib/samagotchi/bundles/skills/plugin.rb +419 -0
  20. data/lib/samagotchi/bundles/source-links/hooks/source_links.rb +178 -5
  21. data/lib/samagotchi/bundles/source-links/manifest.yml +3 -3
  22. data/lib/samagotchi/bundles/source-links/source_links.md +1 -1
  23. data/lib/samagotchi/bundles/system/config_modification_protocol.md +9 -10
  24. data/lib/samagotchi/bundles/system/delegated.md +6 -7
  25. data/lib/samagotchi/bundles/system/identity.md +5 -0
  26. data/lib/samagotchi/bundles/system/manifest.yml +6 -6
  27. data/lib/samagotchi/bundles/system/memory_guide.md +26 -0
  28. data/lib/samagotchi/bundles/system/self_map.md +2 -1
  29. data/lib/samagotchi/client.rb +25 -26
  30. data/lib/samagotchi/commands/registry.rb +8 -0
  31. data/lib/samagotchi/config.rb +97 -113
  32. data/lib/samagotchi/desktop/macos/App.swift +12 -8
  33. data/lib/samagotchi/desktop/macos/ChiRunner.swift +4 -2
  34. data/lib/samagotchi/desktop/macos/Images.swift +113 -0
  35. data/lib/samagotchi/desktop/macos/Info.plist.erb +6 -0
  36. data/lib/samagotchi/desktop/macos/Panel.swift +112 -9
  37. data/lib/samagotchi/desktop/macos.rb +59 -8
  38. data/lib/samagotchi/desktop_command.rb +6 -3
  39. data/lib/samagotchi/edit_preview.rb +82 -0
  40. data/lib/samagotchi/engine.rb +236 -443
  41. data/lib/samagotchi/gem_update.rb +89 -0
  42. data/lib/samagotchi/guardrails/approval.rb +26 -4
  43. data/lib/samagotchi/guardrails/load_failures.rb +9 -3
  44. data/lib/samagotchi/host_registry.rb +8 -12
  45. data/lib/samagotchi/idle_client.rb +24 -15
  46. data/lib/samagotchi/idle_reminders.rb +2 -2
  47. data/lib/samagotchi/image_store.rb +10 -6
  48. data/lib/samagotchi/kernel_loop.rb +59 -123
  49. data/lib/samagotchi/live_versions.rb +65 -0
  50. data/lib/samagotchi/llm/api_key.rb +41 -0
  51. data/lib/samagotchi/llm/chat_loop.rb +77 -13
  52. data/lib/samagotchi/llm/errors.rb +38 -7
  53. data/lib/samagotchi/llm/http.rb +19 -22
  54. data/lib/samagotchi/llm/openai_chat.rb +22 -26
  55. data/lib/samagotchi/memory_bundle/installer.rb +65 -63
  56. data/lib/samagotchi/memory_bundle/provenance.rb +51 -12
  57. data/lib/samagotchi/memory_bundle/shipped_update.rb +157 -0
  58. data/lib/samagotchi/memory_bundle/status.rb +4 -1
  59. data/lib/samagotchi/memory_bundle/system_bundle.rb +81 -53
  60. data/lib/samagotchi/model_profile.rb +27 -10
  61. data/lib/samagotchi/note_command.rb +2 -1
  62. data/lib/samagotchi/prompt.rb +4 -2
  63. data/lib/samagotchi/reminder_store.rb +1 -9
  64. data/lib/samagotchi/reply_wait.rb +48 -4
  65. data/lib/samagotchi/self_report.rb +37 -5
  66. data/lib/samagotchi/send_command.rb +190 -17
  67. data/lib/samagotchi/session.rb +4 -2
  68. data/lib/samagotchi/session_commands.rb +38 -8
  69. data/lib/samagotchi/session_manager.rb +19 -53
  70. data/lib/samagotchi/system_prompt.rb +403 -0
  71. data/lib/samagotchi/terminal_ui/attach_launcher.rb +5 -3
  72. data/lib/samagotchi/terminal_ui/attached_loop.rb +141 -108
  73. data/lib/samagotchi/terminal_ui/attached_view.rb +27 -12
  74. data/lib/samagotchi/terminal_ui/event_renderer.rb +29 -8
  75. data/lib/samagotchi/terminal_ui/formatting.rb +41 -22
  76. data/lib/samagotchi/terminal_ui/input_support.rb +7 -23
  77. data/lib/samagotchi/terminal_ui/plain_surface.rb +13 -7
  78. data/lib/samagotchi/terminal_ui/question_prompt.rb +35 -0
  79. data/lib/samagotchi/terminal_ui/status_row.rb +81 -0
  80. data/lib/samagotchi/terminal_ui/surface.rb +1 -1
  81. data/lib/samagotchi/terminal_ui.rb +142 -923
  82. data/lib/samagotchi/text_diff.rb +181 -0
  83. data/lib/samagotchi/thinking.rb +126 -0
  84. data/lib/samagotchi/tool_activity.rb +52 -2
  85. data/lib/samagotchi/tool_runner.rb +37 -1
  86. data/lib/samagotchi/tools/ask_user_question.rb +41 -33
  87. data/lib/samagotchi/tools/edit.rb +23 -9
  88. data/lib/samagotchi/tools/execute.rb +3 -3
  89. data/lib/samagotchi/tools/output_guardrails.rb +8 -7
  90. data/lib/samagotchi/tools/read.rb +4 -4
  91. data/lib/samagotchi/tools/write.rb +4 -0
  92. data/lib/samagotchi/turn_flow.rb +12 -2
  93. data/lib/samagotchi/update_command.rb +309 -0
  94. data/lib/samagotchi/update_hint.rb +59 -0
  95. data/lib/samagotchi/version.rb +1 -1
  96. data/lib/samagotchi/vision_support.rb +6 -4
  97. data/lib/samagotchi/web/app.rb +173 -38
  98. data/lib/samagotchi/web/lan.rb +99 -0
  99. data/lib/samagotchi/web/message_parts.rb +19 -10
  100. data/lib/samagotchi/web/public/activity.js +10 -0
  101. data/lib/samagotchi/web/public/app.js +135 -78
  102. data/lib/samagotchi/web/public/chat_view.js +8 -1
  103. data/lib/samagotchi/web/public/data.js +2 -0
  104. data/lib/samagotchi/web/public/diff_view.js +58 -0
  105. data/lib/samagotchi/web/public/index.html +185 -18
  106. data/lib/samagotchi/web/public/model_pick.js +136 -0
  107. data/lib/samagotchi/web/public/model_picker.js +224 -0
  108. data/lib/samagotchi/web/public/notify.js +10 -0
  109. data/lib/samagotchi/web/public/question_card.js +3 -1
  110. data/lib/samagotchi/web/public/stage_model.js +110 -0
  111. data/lib/samagotchi/web/public/stage_view.js +580 -0
  112. data/lib/samagotchi/web/public/timing.js +6 -2
  113. data/lib/samagotchi/web/public/turn_events.js +38 -10
  114. data/lib/samagotchi/web/public/turn_model.js +11 -3
  115. data/lib/samagotchi/web/public/turn_view.js +76 -20
  116. data/lib/samagotchi/web/qr.rb +40 -0
  117. data/lib/samagotchi/web/server.rb +101 -11
  118. data/lib/samagotchi/web/token.rb +97 -0
  119. data/lib/samagotchi/worker.rb +5 -4
  120. metadata +38 -3
  121. 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, **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)
@@ -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
@@ -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.* from config, then the SAMAGOTCHI_RETRY_* env, then defaults.
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
- env_float(RETRY_BASE_DELAY_ENV, DEFAULT_RETRY_BASE_DELAY),
51
+ DEFAULT_RETRY_BASE_DELAY,
55
52
  max_delay: config_value("retry.max_delay") { |v| v.is_a?(Numeric) && v.positive? }&.to_f ||
56
- env_float(RETRY_MAX_DELAY_ENV, DEFAULT_RETRY_MAX_DELAY)
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
- def initialize(label:, open_timeout:, read_timeout:, retry_policy: nil, sleeper: nil, first_token_timeout: nil)
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
- raise ProviderErrors.from_response(status: status, body: response.body.to_s, host: @label,
400
- retry_after: response["Retry-After"])
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 = config_integer("server.open_timeout", 10)
188
- read_timeout = config_integer("server.read_timeout", 600)
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
- 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)