samagotchi 0.52.0 → 0.54.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -1
  3. data/README.md +1 -1
  4. data/docs/architecture.md +94 -54
  5. data/docs/configuration.md +1 -2
  6. data/docs/context.md +104 -9
  7. data/docs/internals/background-tasks.md +1 -1
  8. data/docs/internals/bundles.md +73 -0
  9. data/docs/internals/context-telemetry.md +8 -5
  10. data/docs/internals/engine.md +16 -7
  11. data/docs/internals/tool-guardrails.md +8 -6
  12. data/docs/plugins.md +2 -1
  13. data/docs/releasing.md +6 -3
  14. data/docs/sessions.md +1 -1
  15. data/docs/testing.md +5 -3
  16. data/lib/samagotchi/analytics_file.rb +35 -0
  17. data/lib/samagotchi/bridge/turn_accumulator.rb +2 -0
  18. data/lib/samagotchi/bundle_command.rb +35 -44
  19. data/lib/samagotchi/bundle_needs.rb +4 -5
  20. data/lib/samagotchi/bundles/skills/manifest.yml +2 -2
  21. data/lib/samagotchi/bundles/skills/plugin.rb +2 -1
  22. data/lib/samagotchi/bundles/system/config_modification_protocol.md +1 -1
  23. data/lib/samagotchi/bundles/system/manifest.yml +2 -2
  24. data/lib/samagotchi/child_move.rb +6 -4
  25. data/lib/samagotchi/config.rb +0 -1
  26. data/lib/samagotchi/context_command.rb +1 -1
  27. data/lib/samagotchi/context_providers.rb +11 -14
  28. data/lib/samagotchi/context_status.rb +10 -18
  29. data/lib/samagotchi/engine.rb +132 -135
  30. data/lib/samagotchi/extension_load.rb +102 -0
  31. data/lib/samagotchi/guardrail_wiring.rb +16 -16
  32. data/lib/samagotchi/hooks/bundle_loader.rb +9 -8
  33. data/lib/samagotchi/host_registry.rb +13 -0
  34. data/lib/samagotchi/kernel_loop.rb +5 -3
  35. data/lib/samagotchi/llm/chat_loop.rb +5 -4
  36. data/lib/samagotchi/memory_bundle/asset_installer.rb +302 -0
  37. data/lib/samagotchi/memory_bundle/builder.rb +184 -190
  38. data/lib/samagotchi/memory_bundle/bundle_hook.rb +51 -0
  39. data/lib/samagotchi/memory_bundle/installed_bundle.rb +90 -0
  40. data/lib/samagotchi/memory_bundle/installer.rb +252 -488
  41. data/lib/samagotchi/memory_bundle/listing.rb +6 -6
  42. data/lib/samagotchi/memory_bundle/manifest.rb +9 -29
  43. data/lib/samagotchi/memory_bundle/plugin_ref.rb +27 -0
  44. data/lib/samagotchi/memory_bundle/profile.rb +21 -21
  45. data/lib/samagotchi/memory_bundle/provenance.rb +46 -57
  46. data/lib/samagotchi/memory_bundle/shipped_update.rb +24 -26
  47. data/lib/samagotchi/memory_bundle/status.rb +22 -24
  48. data/lib/samagotchi/memory_bundle/system_bundle.rb +4 -5
  49. data/lib/samagotchi/memory_bundle/uninstaller.rb +8 -10
  50. data/lib/samagotchi/plugin/loader.rb +8 -8
  51. data/lib/samagotchi/self_report.rb +2 -2
  52. data/lib/samagotchi/send_command.rb +4 -28
  53. data/lib/samagotchi/session_commands.rb +32 -4
  54. data/lib/samagotchi/session_continue.rb +275 -0
  55. data/lib/samagotchi/session_manager.rb +45 -257
  56. data/lib/samagotchi/session_metrics.rb +18 -21
  57. data/lib/samagotchi/sessions_command.rb +3 -2
  58. data/lib/samagotchi/terminal_ui/attached_loop.rb +2 -1
  59. data/lib/samagotchi/terminal_ui/formatting.rb +5 -1
  60. data/lib/samagotchi/terminal_ui.rb +25 -3
  61. data/lib/samagotchi/tool_activity.rb +84 -14
  62. data/lib/samagotchi/version.rb +1 -1
  63. data/lib/samagotchi/web/app.rb +3 -8
  64. data/lib/samagotchi/web/public/activity.js +2 -0
  65. data/lib/samagotchi/web/public/app.js +6 -8
  66. data/lib/samagotchi/web/public/command_complete.js +7 -2
  67. data/lib/samagotchi/web/public/ctx.js +18 -4
  68. data/lib/samagotchi/web/public/index.html +4 -2
  69. data/lib/samagotchi/web/public/question_card.js +9 -1
  70. data/lib/samagotchi/web/public/question_cards.js +5 -3
  71. data/lib/samagotchi/web/public/thinking_chip.js +31 -21
  72. data/lib/samagotchi/web/public/timing.js +8 -2
  73. data/lib/samagotchi/web/public/turn_html.js +10 -6
  74. metadata +9 -1
@@ -151,26 +151,26 @@ module Samagotchi
151
151
  def bundle_rules
152
152
  require_relative "memory_bundle/provenance"
153
153
  rules = []
154
- MemoryBundle::Provenance.each_installed(holding: :guardrails) do |bundle_name, data|
155
- if data[:error]
156
- Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{data[:error]}", bundle: bundle_name)
157
- @failures.add("rules (bundle #{bundle_name})", data[:error], required: true, group: :rules)
154
+ MemoryBundle::Provenance.each_installed(holding: :guardrails) do |bundle_name, bundle|
155
+ if bundle.error?
156
+ Log.warn(:guardrails, "bundle_rules_invalid", echo: "[samagotchi:guardrails] bundle #{bundle_name}: #{bundle.error}", bundle: bundle_name)
157
+ @failures.add("rules (bundle #{bundle_name})", bundle.error, required: true, group: :rules)
158
158
  next
159
159
  end
160
- if (too_new = MemoryBundle::Manifest.requires_chi_failure(data[:requires_chi], Samagotchi::VERSION))
161
- rules.concat(too_new_bundle_rules(bundle_name, data, too_new))
160
+ if (too_new = MemoryBundle::Manifest.requires_chi_failure(bundle.requires_chi, Samagotchi::VERSION))
161
+ rules.concat(too_new_bundle_rules(bundle_name, bundle, too_new))
162
162
  next
163
163
  end
164
164
  dir = MemoryBundle::Provenance.new(name: bundle_name).guardrails_dir
165
165
  loaded = []
166
166
  whole = true
167
- data[:guardrails].sort_by { |k, _| k.to_s }.each do |basename, meta|
167
+ bundle.guardrails.sort.each do |basename, sha|
168
168
  what = "rules #{basename} (bundle #{bundle_name})"
169
- path = File.join(dir, basename.to_s)
169
+ path = File.join(dir, basename)
170
170
  begin
171
171
  raise Guardrails::Rules::ParseError, "the file is missing" unless File.file?(path)
172
172
 
173
- unless MemoryBundle::Provenance.sha_matches?(path, meta.is_a?(Hash) ? meta[:sha256] : nil)
173
+ unless MemoryBundle::Provenance.sha_matches?(path, sha)
174
174
  raise Guardrails::Rules::ParseError, "its sha256 differs from the installed one (edited after install? reinstall the bundle)"
175
175
  end
176
176
 
@@ -256,7 +256,7 @@ module Samagotchi
256
256
  # load the new ones). Never loaded (a chi older than the bundle it
257
257
  # starts with): none, and a required load failure, as the hook loader
258
258
  # does for a guardrail hook, so the calls aren't quietly unguarded.
259
- def too_new_bundle_rules(bundle_name, data, reason)
259
+ def too_new_bundle_rules(bundle_name, bundle, reason)
260
260
  kept = @loaded_bundle_rules[bundle_name]
261
261
  unless kept
262
262
  Log.warn(:guardrails, "bundle_rules_requires_chi", echo: "[samagotchi:guardrails] bundle #{bundle_name}: its rules are not loaded: #{reason}",
@@ -266,19 +266,19 @@ module Samagotchi
266
266
  return []
267
267
  end
268
268
 
269
- key = [bundle_name, data[:version].to_s]
269
+ key = [bundle_name, bundle.version.to_s]
270
270
  return kept if @kept_noticed[key]
271
271
 
272
272
  @kept_noticed[key] = true
273
- Log.warn(:guardrails, "bundle_rules_kept", bundle: bundle_name, version: data[:version].to_s,
274
- requires_chi: data[:requires_chi].to_s)
275
- @notify.call(kept_notice(bundle_name, data))
273
+ Log.warn(:guardrails, "bundle_rules_kept", bundle: bundle_name, version: bundle.version.to_s,
274
+ requires_chi: bundle.requires_chi.to_s)
275
+ @notify.call(kept_notice(bundle_name, bundle))
276
276
  kept
277
277
  end
278
278
 
279
- def kept_notice(bundle_name, data)
279
+ def kept_notice(bundle_name, bundle)
280
280
  id = @session_lookup.call&.id || "ID"
281
- "bundle #{bundle_name} #{data[:version]} needs chi #{data[:requires_chi]} and this session runs chi #{Samagotchi::VERSION}: " \
281
+ "bundle #{bundle_name} #{bundle.version} needs chi #{bundle.requires_chi} and this session runs chi #{Samagotchi::VERSION}: " \
282
282
  "its new rules are not loaded, the ones loaded before still apply. Restart the session to load them " \
283
283
  "(chi sessions stop #{id}, then chi --resume #{id})"
284
284
  end
@@ -3,6 +3,7 @@
3
3
  require_relative "registry"
4
4
  require "digest"
5
5
  require_relative "../log"
6
+ require_relative "../memory_bundle/bundle_hook"
6
7
  require_relative "../memory_bundle/provenance"
7
8
  require_relative "../memory_bundle/manifest"
8
9
  require_relative "../version"
@@ -44,7 +45,8 @@ module Samagotchi
44
45
  #
45
46
  # @param bundle_name [String]
46
47
  # @param hooks_dir [String] directory containing the bundle's .rb hook files
47
- # @param metadata [Hash] basename => { event:, on_error:, priority: }
48
+ # @param metadata [Hash] basename => { event:, on_error:, priority:,
49
+ # sha256: } (MemoryBundle::BundleHook.parse reads it)
48
50
  # @param registry [Samagotchi::Hooks::Registry]
49
51
  # @param failures [Guardrails::LoadFailures, nil] collects hooks that
50
52
  # failed to load
@@ -64,14 +66,13 @@ module Samagotchi
64
66
  basename = raw_basename.to_s
65
67
  next if basename.empty?
66
68
 
67
- meta = ->(key) { raw_meta && (raw_meta[key] || raw_meta[key.to_s]) }
68
- event = meta.call(:event).to_s
69
+ hook = MemoryBundle::BundleHook.parse(raw_meta)
69
70
  # A hook with no declared event cannot be auto-registered.
70
- next if event.empty?
71
+ next if hook.event.empty?
71
72
 
72
- event_sym = event.to_sym
73
- on_error = (meta.call(:on_error) || "skip").to_s
74
- priority = (meta.call(:priority) || 100).to_i
73
+ event_sym = hook.event.to_sym
74
+ on_error = hook.on_error
75
+ priority = hook.priority
75
76
  fail_closed = on_error == "fail_closed" && event_sym == :before_tool_call
76
77
  what = "hook #{basename} (bundle #{bundle_name})"
77
78
  file = hooks_dir && File.join(hooks_dir, basename)
@@ -79,7 +80,7 @@ module Samagotchi
79
80
  failures&.add(what, "the file is missing", required: true) if fail_closed
80
81
  next
81
82
  end
82
- if (mismatch = too_old || sha_mismatch(file, meta.call(:sha256), required: fail_closed))
83
+ if (mismatch = too_old || sha_mismatch(file, hook.sha256, required: fail_closed))
83
84
  Log.warn(:hooks, "bundle_hook_mismatch", echo: "[samagotchi:hooks] bundle '#{bundle_name}' hook '#{basename}' not loaded: #{mismatch}", bundle: bundle_name, hook: basename.to_s)
84
85
  failures&.add(what, mismatch, required: fail_closed)
85
86
  next
@@ -273,6 +273,19 @@ module Samagotchi
273
273
  ModelTarget.new(model: raw_model, entry: entry, bare_model: bare, client: client_for(entry))
274
274
  end
275
275
 
276
+ # What the alias +raw_model+ became, for an error that names only the
277
+ # host ("splash is an alias: gemma-x on host main"), or nil when it
278
+ # isn't an alias. An alias without a host of its own goes where a bare
279
+ # id goes (#host_for_model), often the default host the user never typed.
280
+ # @return [String, nil]
281
+ def alias_note(raw_model)
282
+ ref = model_ref(raw_model)
283
+ return nil unless ref.alias_name
284
+
285
+ target = resolve(raw_model)
286
+ "#{ref.alias_name} is an alias: #{target.bare_model} on host #{target.entry.name}"
287
+ end
288
+
276
289
  # The model id sent for +full_ref+: its alias applied, a known host
277
290
  # prefix stripped ("box:gemma" → "gemma").
278
291
  def bare_name(full_ref)
@@ -726,9 +726,11 @@ module Samagotchi
726
726
  end
727
727
 
728
728
  # Estimate context usage for this iteration's prompt (ContextStatus#observe)
729
- # and, when the emit gate fires, surface it to stream consumers as a
730
- # :context_status event. The telemetry is not for the model (it used to
731
- # be injected as a synthetic system message).
729
+ # and surface it to stream consumers as a :context_status event, on every
730
+ # request (observe returns the estimate each time it is enabled), so the
731
+ # web and the attached TUI get a fresh number per request. The telemetry
732
+ # is not for the model (it used to be injected as a synthetic system
733
+ # message); the model's own line is still left only on a rise.
732
734
  def emit_context_status_event(on_stream_event, context, prompt, iteration_index:, window:, image_tokens: 0)
733
735
  event = context.observe(prompt.length, iteration_index: iteration_index, window: window, image_tokens: image_tokens)
734
736
  return unless event
@@ -651,10 +651,11 @@ module Samagotchi
651
651
 
652
652
  # Before a request, as the native loop: estimate how full the window
653
653
  # is (the server's last count plus what the turn appended since),
654
- # emit :context_status on a bucket change, and put the model's own
655
- # line on the tail on a rise that asks it for a change. The estimate
656
- # counts the conversation's text and tool calls, not the tool
657
- # schemas (the server's count does).
654
+ # emit :context_status on every request (observe returns the estimate
655
+ # each time it is enabled), and put the model's own line on the tail
656
+ # on a rise that asks it for a change. The estimate counts the
657
+ # conversation's text and tool calls, not the tool schemas (the
658
+ # server's count does).
658
659
  def observe_context(iteration, window)
659
660
  return unless window
660
661
 
@@ -0,0 +1,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "digest"
5
+ require_relative "bundle_hook"
6
+ require_relative "manifest"
7
+ require_relative "provenance"
8
+ require_relative "../version"
9
+
10
+ module Samagotchi
11
+ module MemoryBundle
12
+ autoload :Installer, File.expand_path("installer", __dir__)
13
+
14
+ # The install of a bundle's parts that live in its own dir
15
+ # (<bundles>/<name>/, Provenance#bundle_dir) rather than in the
16
+ # memories dir: hooks/*.rb, guardrails/*.yml, the plugin file and
17
+ # scripts/*. Each replaces what an earlier version installed. Installer
18
+ # runs it after the memories; it adds to the installer's results and
19
+ # warnings, and #run returns what the record (Provenance#write) keeps.
20
+ class AssetInstaller
21
+ # What was installed: the paths Provenance#write records (each
22
+ # empty / nil on a dry run).
23
+ # hooks every hook basename the bundle ships
24
+ # hook_files, guardrail_files, script_files
25
+ # basename => installed path
26
+ # plugin_file the installed plugin file, nil without one
27
+ Installed = Data.define(:hooks, :hook_files, :guardrail_files, :plugin_file, :script_files)
28
+
29
+ # @param source_dir [String] the normalized bundle source
30
+ # @param manifest [Manifest, nil] nil for a non-strict install without one
31
+ # @param existing [InstalledBundle, nil] the record before this install
32
+ # @param results, warnings, conflicts the Installer's (results and
33
+ # warnings are added to)
34
+ def initialize(name:, source_dir:, manifest:, provenance:, existing:, results:, warnings:, conflicts:,
35
+ force: false, strict: true, upgrade: false, dry_run: false)
36
+ @name = name
37
+ @bundle_dir = source_dir
38
+ @manifest = manifest
39
+ @provenance = provenance
40
+ @existing = existing
41
+ @results = results
42
+ @warnings = warnings
43
+ @conflicts = conflicts
44
+ @force = force
45
+ @strict = strict
46
+ @upgrade = upgrade
47
+ @dry_run = dry_run
48
+ end
49
+
50
+ # @return [Installed]
51
+ # @raise [Installer::InstallError] the manifest names a plugin or
52
+ # script the bundle doesn't have
53
+ def run
54
+ install_hooks
55
+ prune_dropped_hooks if @upgrade && @existing && !@dry_run
56
+ verify_hook_checksums if @manifest && @strict
57
+ incoming_rules = Dir.glob(File.join(@bundle_dir, "guardrails", "*.yml")).sort
58
+ guardrail_files = install_guardrails(incoming_rules)
59
+ plugin_file = install_plugin
60
+ script_files = install_scripts
61
+ warn_hooks_requires_chi
62
+ warn_rules_requires_chi(incoming_rules)
63
+ Installed.new(hooks: @bundle_hooks, hook_files: @hook_files, guardrail_files: guardrail_files,
64
+ plugin_file: plugin_file, script_files: script_files)
65
+ end
66
+
67
+ private
68
+
69
+ # Copies the bundle's hooks/*.rb into <bundle_dir>/hooks/: the ones
70
+ # its manifest lists, else (no hooks: in it) every hooks/*.rb, which is
71
+ # then added to manifest.hooks with no event (the loader skips it, the
72
+ # record keeps it). Without a manifest every hooks/*.rb is copied.
73
+ # Sets @bundle_hooks (every hook the bundle ships) and @hook_files
74
+ # (basename => installed path, for the record).
75
+ def install_hooks
76
+ @bundle_hooks = []
77
+ @hook_files = {}
78
+ return install_unlisted_hooks unless @manifest
79
+
80
+ hook_entries = @manifest.hooks
81
+ if hook_entries.empty?
82
+ Dir.glob(File.join(@bundle_dir, "hooks", "*.rb")).each { |p| hook_entries[File.basename(p)] = BundleHook.new }
83
+ end
84
+ hook_entries.each_key do |basename|
85
+ next unless basename.is_a?(String) && !basename.empty?
86
+
87
+ @bundle_hooks << basename
88
+ src = File.join(@bundle_dir, "hooks", basename)
89
+ install_hook(basename, src) if File.exist?(src)
90
+ end
91
+ end
92
+
93
+ # One listed hook; an upgrade overwrites a local edit with a warning.
94
+ def install_hook(basename, src)
95
+ dest = File.join(@provenance.hooks_dir, basename)
96
+ if @dry_run
97
+ @results[basename] = { status: File.exist?(dest) && !@force ? "would_skip" : "would_install" }
98
+ return
99
+ end
100
+
101
+ prev_sha = @existing&.hooks&.dig(basename)&.sha256
102
+ if @upgrade && File.exist?(dest) && !@force && !Provenance.recorded_sha(prev_sha).empty? && !Provenance.sha_matches?(dest, prev_sha)
103
+ @warnings << "Hook #{basename} was locally modified; overwriting"
104
+ end
105
+
106
+ FileUtils.mkdir_p(@provenance.hooks_dir)
107
+ unchanged = File.exist?(dest) && FileUtils.identical?(src, dest)
108
+ FileUtils.cp(src, dest)
109
+ @results[basename] = if unchanged
110
+ { status: "skipped", reason: "already up to date" }
111
+ elsif @upgrade && @existing
112
+ { status: "updated" }
113
+ else
114
+ { status: "installed" }
115
+ end
116
+ @hook_files[basename] = dest
117
+ end
118
+
119
+ # No manifest (a non-strict install): every hooks/*.rb is copied.
120
+ def install_unlisted_hooks
121
+ Dir.glob(File.join(@bundle_dir, "hooks", "*.rb")).each do |src|
122
+ basename = File.basename(src)
123
+ @bundle_hooks << basename
124
+ if @dry_run
125
+ @results[basename] = { status: "would_install" }
126
+ else
127
+ FileUtils.mkdir_p(@provenance.hooks_dir)
128
+ dest = File.join(@provenance.hooks_dir, basename)
129
+ FileUtils.cp(src, dest)
130
+ @results[basename] = { status: "installed" }
131
+ @hook_files[basename] = dest
132
+ end
133
+ end
134
+ end
135
+
136
+ # An upgrade removes the hooks the previous version shipped and this
137
+ # one doesn't (a local file too, with a warning).
138
+ def prune_dropped_hooks
139
+ @existing.hooks.each_key do |old_key_str|
140
+ next if @bundle_hooks.include?(old_key_str)
141
+
142
+ old_dest = File.join(@provenance.hooks_dir, old_key_str)
143
+ if File.exist?(old_dest) && !@force
144
+ @warnings << "Bundle no longer includes hook #{old_key_str} but local file exists — removing"
145
+ end
146
+ FileUtils.rm_f(old_dest)
147
+ end
148
+ end
149
+
150
+ # Strict mode: an installed hook whose sha256 differs from the one its
151
+ # manifest declares warns (as verify_memory_checksums does).
152
+ def verify_hook_checksums
153
+ @bundle_hooks.each do |basename|
154
+ next if @conflicts.key?(basename)
155
+
156
+ expected = @manifest.checksum_for_hook(basename)
157
+ next unless expected
158
+
159
+ dest = File.join(@provenance.hooks_dir, basename)
160
+ next unless File.exist?(dest)
161
+
162
+ actual = Digest::SHA256.hexdigest(File.read(dest))
163
+ if actual != expected
164
+ @warnings << "Checksum mismatch for hook #{basename}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
165
+ end
166
+ end
167
+ end
168
+
169
+ # The bundle's guardrails/*.yml into <bundle_dir>/guardrails/: the
170
+ # set replaces what an earlier version installed. Same bytes as
171
+ # installed are reported up to date, not "Installed".
172
+ # @return [Hash{String => String}] basename => installed path ({} on a dry run)
173
+ def install_guardrails(incoming_rules)
174
+ if @dry_run
175
+ incoming_rules.each { |src| @results["guardrails/#{File.basename(src)}"] = { status: "would_install" } }
176
+ return {}
177
+ end
178
+
179
+ target = @provenance.guardrails_dir
180
+ unchanged = incoming_rules.select do |src|
181
+ dest = File.join(target, File.basename(src))
182
+ File.file?(dest) && FileUtils.identical?(src, dest)
183
+ end
184
+ FileUtils.rm_rf(target)
185
+ return {} if incoming_rules.empty?
186
+
187
+ FileUtils.mkdir_p(target)
188
+ incoming_rules.to_h do |src|
189
+ dest = File.join(target, File.basename(src))
190
+ FileUtils.cp(src, dest)
191
+ @results["guardrails/#{File.basename(src)}"] =
192
+ unchanged.include?(src) ? { status: "skipped", reason: "already up to date" } : { status: "installed" }
193
+ [File.basename(src), dest]
194
+ end
195
+ end
196
+
197
+ # Copy the manifest's plugin file into the bundle's plugin/ dir,
198
+ # warning when its sha256 differs from the declared one or this chi
199
+ # doesn't meet requires_chi (the Engine then won't load it).
200
+ # @return [String, nil] the installed file (nil: none, or a dry run)
201
+ def install_plugin
202
+ plugin = @manifest&.plugin
203
+ unless plugin
204
+ FileUtils.rm_rf(@provenance.plugin_dir) unless @dry_run
205
+ return nil
206
+ end
207
+
208
+ src = File.join(@bundle_dir, plugin[:file])
209
+ raise Installer::InstallError, "the manifest names plugin #{plugin[:file]}, which the bundle doesn't have" unless File.file?(src)
210
+
211
+ if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, Samagotchi::VERSION))
212
+ @warnings << "Plugin #{plugin[:file]} won't load: #{failure}"
213
+ end
214
+ if @dry_run
215
+ @results[plugin[:file]] = { status: "would_install" }
216
+ return nil
217
+ end
218
+
219
+ dest = File.join(@provenance.plugin_dir, plugin[:file])
220
+ unchanged = File.file?(dest) && FileUtils.identical?(src, dest)
221
+ FileUtils.rm_rf(@provenance.plugin_dir)
222
+ FileUtils.mkdir_p(@provenance.plugin_dir)
223
+ FileUtils.cp(src, dest)
224
+ @results[plugin[:file]] = if unchanged
225
+ { status: "skipped", reason: "already up to date" }
226
+ elsif @upgrade && @provenance.installed?
227
+ { status: "updated" }
228
+ else
229
+ { status: "installed" }
230
+ end
231
+ expected = @manifest.checksum_for_plugin
232
+ actual = Digest::SHA256.hexdigest(File.binread(dest))
233
+ if @strict && expected && actual != expected
234
+ @warnings << "Checksum mismatch for plugin #{plugin[:file]}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
235
+ end
236
+ dest
237
+ end
238
+
239
+ # The manifest's scripts, copied into <bundle_dir>/scripts/ (the
240
+ # folder replaced: a script the new version dropped goes). A declared
241
+ # sha256 that differs warns, as a plugin's does.
242
+ # @return [Hash{String => String}] file name => installed path
243
+ def install_scripts
244
+ scripts = @manifest&.scripts || {}
245
+ scripts.each_key do |file|
246
+ next if File.file?(File.join(@bundle_dir, "scripts", file))
247
+
248
+ raise Installer::InstallError, "the manifest names scripts/#{file}, which the bundle doesn't have"
249
+ end
250
+ return {} if @dry_run
251
+
252
+ unchanged = scripts.keys.select do |file|
253
+ dest = File.join(@provenance.scripts_dir, file)
254
+ File.file?(dest) && FileUtils.identical?(File.join(@bundle_dir, "scripts", file), dest)
255
+ end
256
+ FileUtils.rm_rf(@provenance.scripts_dir)
257
+ return {} if scripts.empty?
258
+
259
+ FileUtils.mkdir_p(@provenance.scripts_dir)
260
+ scripts.to_h do |file, declared|
261
+ dest = File.join(@provenance.scripts_dir, file)
262
+ FileUtils.cp(File.join(@bundle_dir, "scripts", file), dest)
263
+ @results["scripts/#{file}"] = if unchanged.include?(file) then { status: "skipped", reason: "already up to date" }
264
+ elsif @upgrade && @provenance.installed? then { status: "updated" }
265
+ else { status: "installed" }
266
+ end
267
+ FileUtils.chmod(0o755, dest)
268
+ actual = Digest::SHA256.hexdigest(File.binread(dest))
269
+ expected = declared.to_s.delete_prefix("sha256:")
270
+ if @strict && !expected.empty? && actual != expected
271
+ @warnings << "Checksum mismatch for script #{file}: expected #{expected[0..7]}..., got #{actual[0..7]}..."
272
+ end
273
+ [file, dest]
274
+ end
275
+ end
276
+
277
+ # The hook loader skips every hook of a bundle whose requires_chi this
278
+ # chi doesn't meet (Hooks::BundleLoader); say so at install, as
279
+ # install_plugin does for the plugin.
280
+ def warn_hooks_requires_chi
281
+ return if @manifest.nil? || @bundle_hooks.empty?
282
+
283
+ if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, Samagotchi::VERSION))
284
+ @warnings << "Bundle #{@name}: its hooks won't load: #{failure}"
285
+ end
286
+ end
287
+
288
+ # GuardrailWiring#bundle_rules doesn't read the rules of a bundle whose
289
+ # requires_chi this chi doesn't meet, and a session that never loaded
290
+ # them records a required load failure (the gate refuses guarded calls
291
+ # until chi is updated): say so at install, as the hooks warning does.
292
+ def warn_rules_requires_chi(rule_files)
293
+ return if @manifest.nil? || rule_files.empty?
294
+
295
+ if (failure = Manifest.requires_chi_failure(@manifest.requires_chi, Samagotchi::VERSION))
296
+ @warnings << "Bundle #{@name}: its guardrail rules won't load: #{failure}; until chi is updated " \
297
+ "(chi update), guarded tool calls are refused"
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end